From ce7a54d5b117f5e45deb1504ec059d0839b6cace Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 14 Sep 2026 02:55:02 +0200 Subject: [PATCH] fix(admin): distinguish unavailable diagnostic archives --- README.md | 15 +++++ frontend/src/adminDiagnostics.test.ts | 35 +++++++++++ frontend/src/support.ts | 1 + frontend/src/views/AdminReportsView.vue | 8 ++- internal/domain/admin.go | 23 ++++---- .../intake/diagnostics_availability_test.go | 59 +++++++++++++++++++ internal/intake/service.go | 48 ++++++++++----- internal/store/objects.go | 20 +++++++ internal/web/admin.go | 2 + internal/web/diagnostics_availability_test.go | 24 ++++++++ openapi.yaml | 6 +- 11 files changed, 211 insertions(+), 30 deletions(-) create mode 100644 frontend/src/adminDiagnostics.test.ts create mode 100644 internal/intake/diagnostics_availability_test.go create mode 100644 internal/web/diagnostics_availability_test.go diff --git a/README.md b/README.md index dfb2aff..1654e3d 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,18 @@ See [docs/threat-model.md](docs/threat-model.md), [docs/operations.md](docs/oper ## License GNU Affero General Public License v3.0. See [LICENSE](LICENSE). + +## Diagnostic archive availability + +Admin responses retain `hasDiagnostics` as evidence that an archive was attached. +The additive `diagnosticsState` field reports `none`, `available`, `unavailable`, +or `unknown` after checking private object storage. Missing objects do not delete +report metadata or change report status. Downloads of a missing attached object +return `503 diagnostics_unavailable`; an unknown report still returns 404. + +Production deployments must reuse the same persistent `/data` volume on every +redeploy, together with the database and encryption key. The Dockerfile volume +declaration alone does not ensure that a deployment platform reattaches the same +volume. Back up encrypted objects, database records, and the separately protected +key together. Restore missing objects from a verified backup; do not mark their +reports resolved merely because metadata remains readable. diff --git a/frontend/src/adminDiagnostics.test.ts b/frontend/src/adminDiagnostics.test.ts new file mode 100644 index 0000000..f58aecf --- /dev/null +++ b/frontend/src/adminDiagnostics.test.ts @@ -0,0 +1,35 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' +import AdminReportsView from './views/AdminReportsView.vue' +import { loadAdminReport, loadAdminReports, loadAdminSession, type AdminReportDetail } from './support' + +vi.mock('./support', async (loadOriginal) => ({ + ...await loadOriginal(), + loadAdminSession: vi.fn(), loadAdminReports: vi.fn(), loadAdminReport: vi.fn(), +})) + +describe('private diagnostic availability', () => { + it.each(['unavailable', 'unknown', 'available'] as const)('renders %s without losing report details', async (diagnosticsState) => { + const report: AdminReportDetail = { + id: '11111111-1111-4111-8111-111111111111', supportCode: 'OBI-TEST1-TEST2', productId: 'example', + requestType: 'bug', status: 'accepted', source: 'app', title: 'Synthetic report', + hasDiagnostics: true, diagnosticsState, createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', retentionUntil: '2026-10-01T00:00:00Z', + description: 'Synthetic reproduction steps', release: { version: '1.0', platform: 'Linux' }, messages: [], + } + vi.mocked(loadAdminSession).mockResolvedValue({ contractVersion: 1, username: 'maintainer', csrfToken: 'synthetic', expiresAt: '2026-10-01T00:00:00Z' }) + vi.mocked(loadAdminReports).mockResolvedValue({ contractVersion: 1, reports: [report], total: 1, limit: 25, offset: 0 }) + vi.mocked(loadAdminReport).mockResolvedValue(report) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }) + const wrapper = mount(AdminReportsView, { global: { plugins: [router] } }) + await flushPromises() + await wrapper.get('.report-list button').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Synthetic reproduction steps') + expect(wrapper.find('.diagnostic-download').exists()).toBe(diagnosticsState === 'available') + if (diagnosticsState === 'unavailable') expect(wrapper.text()).toContain('diagnostic ZIP is unavailable') + if (diagnosticsState === 'unknown') expect(wrapper.text()).toContain('Diagnostic storage could not be checked') + wrapper.unmount() + }) +}) diff --git a/frontend/src/support.ts b/frontend/src/support.ts index 2f43b8a..5b01000 100644 --- a/frontend/src/support.ts +++ b/frontend/src/support.ts @@ -80,6 +80,7 @@ export interface AdminReportSummary { source: 'web' | 'app' title: string hasDiagnostics: boolean + diagnosticsState?: 'none' | 'available' | 'unavailable' | 'unknown' createdAt: string updatedAt: string retentionUntil: string diff --git a/frontend/src/views/AdminReportsView.vue b/frontend/src/views/AdminReportsView.vue index 68f50d5..610f134 100644 --- a/frontend/src/views/AdminReportsView.vue +++ b/frontend/src/views/AdminReportsView.vue @@ -133,7 +133,9 @@ function readable(value: string): string { {{ report.supportCode }}{{ readable(report.status) }} {{ report.title }} {{ report.productId }} · {{ report.source }} · {{ formatDate(report.createdAt) }} - Diagnostic ZIP attached + Diagnostic ZIP unavailable + Diagnostic storage could not be checked + Diagnostic ZIP attached

No reports match this status.

@@ -167,7 +169,9 @@ function readable(value: string): string {
- Download diagnostic ZIP + Download diagnostic ZIP +

The report is retained, but its diagnostic ZIP is unavailable. Check the private archive storage or request a new diagnostic report.

+

Diagnostic storage could not be checked. Reload this report after storage is available.

Private report data must not be copied into a public issue without reviewing and removing personal information.

Select a report to review its private details.

diff --git a/internal/domain/admin.go b/internal/domain/admin.go index 7c47c30..c504502 100644 --- a/internal/domain/admin.go +++ b/internal/domain/admin.go @@ -19,17 +19,18 @@ type AdminAudit struct { } type AdminReportSummary struct { - ID string `json:"id"` - SupportCode string `json:"supportCode"` - ProductID string `json:"productId"` - RequestType RequestType `json:"requestType"` - Status ReportStatus `json:"status"` - Source string `json:"source"` - Title string `json:"title"` - HasDiagnostics bool `json:"hasDiagnostics"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - RetentionUntil time.Time `json:"retentionUntil"` + DiagnosticsState string `json:"diagnosticsState"` + ID string `json:"id"` + SupportCode string `json:"supportCode"` + ProductID string `json:"productId"` + RequestType RequestType `json:"requestType"` + Status ReportStatus `json:"status"` + Source string `json:"source"` + Title string `json:"title"` + HasDiagnostics bool `json:"hasDiagnostics"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RetentionUntil time.Time `json:"retentionUntil"` } type AdminReportDetail struct { diff --git a/internal/intake/diagnostics_availability_test.go b/internal/intake/diagnostics_availability_test.go new file mode 100644 index 0000000..c7bcbec --- /dev/null +++ b/internal/intake/diagnostics_availability_test.go @@ -0,0 +1,59 @@ +package intake + +import ( + "context" + "errors" + "testing" + + "github.com/obiente/support/internal/store" +) + +func TestMissingDiagnosticObjectPreservesReportAndAdvertisesUnavailable(t *testing.T) { + service, _, objects := testService(t) + if _, err := service.Submit(context.Background(), validSubmission(t)); err != nil { + t.Fatal(err) + } + reports, _, err := service.AdminList(context.Background(), nil, 25, 0) + if err != nil || len(reports) != 1 { + t.Fatalf("list: %v", err) + } + id := reports[0].ID + if reports[0].DiagnosticsState != "available" { + t.Fatal("attached archive is not available") + } + for key := range objects.Values { + delete(objects.Values, key) + } + detail, err := service.AdminDetail(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if !detail.HasDiagnostics || detail.DiagnosticsState != "unavailable" { + t.Fatal("missing attachment state was lost") + } + if _, _, err := service.AdminDiagnostics(context.Background(), id); !errors.Is(err, ErrDiagnosticsUnavailable) { + t.Fatalf("download error: %v", err) + } + reports, _, err = service.AdminList(context.Background(), nil, 25, 0) + if err != nil || reports[0].DiagnosticsState != "unavailable" { + t.Fatal("list did not retain unavailable report") + } +} + +func TestObjectStorageFailureIsUnknownNotMissing(t *testing.T) { + service, _, objects := testService(t) + if _, err := service.Submit(context.Background(), validSubmission(t)); err != nil { + t.Fatal(err) + } + service.objects = failingObjectProbe{objects} + reports, _, err := service.AdminList(context.Background(), nil, 25, 0) + if err != nil || reports[0].DiagnosticsState != "unknown" { + t.Fatal("storage outage should preserve report metadata") + } +} + +type failingObjectProbe struct{ *store.MemoryObjects } + +func (failingObjectProbe) Exists(string) (bool, error) { + return false, errors.New("synthetic storage outage") +} diff --git a/internal/intake/service.go b/internal/intake/service.go index f565a17..94eca3d 100644 --- a/internal/intake/service.go +++ b/internal/intake/service.go @@ -26,13 +26,14 @@ import ( ) var ( - ErrInvalid = errors.New("invalid report") - ErrNotFound = errors.New("report not found") - ErrKeyReused = errors.New("idempotency key was already used for another report") - ErrCancelled = errors.New("report submission was cancelled") - idempotencyKey = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) - productID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`) - archiveFileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$`) + ErrInvalid = errors.New("invalid report") + ErrNotFound = errors.New("report not found") + ErrDiagnosticsUnavailable = errors.New("diagnostic archive unavailable") + ErrKeyReused = errors.New("idempotency key was already used for another report") + ErrCancelled = errors.New("report submission was cancelled") + idempotencyKey = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) + productID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`) + archiveFileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$`) ) type Submission struct { @@ -284,7 +285,7 @@ func (service *Service) AdminList(ctx context.Context, status *domain.ReportStat if openErr != nil { return nil, 0, openErr } - result = append(result, adminSummary(report, payload)) + result = append(result, service.adminSummary(report, payload)) } return result, total, nil } @@ -305,7 +306,7 @@ func (service *Service) AdminDetail(ctx context.Context, id string) (domain.Admi if err != nil { return domain.AdminReportDetail{}, err } - return adminDetail(report, payload, messages), nil + return service.adminDetail(report, payload, messages), nil } func (service *Service) AdminDiagnostics(ctx context.Context, id string) ([]byte, string, error) { @@ -318,7 +319,7 @@ func (service *Service) AdminDiagnostics(ctx context.Context, id string) ([]byte } content, err := service.objects.Get(*report.DiagnosticObjectKey, report.ID) if errors.Is(err, store.ErrNotFound) { - return nil, "", ErrNotFound + return nil, "", ErrDiagnosticsUnavailable } return content, report.SupportCode + "-diagnostics.zip", err } @@ -342,7 +343,7 @@ func (service *Service) AdminUpdateStatus(ctx context.Context, id string, status if err != nil { return domain.AdminReportDetail{}, err } - return adminDetail(report, payload, messages), nil + return service.adminDetail(report, payload, messages), nil } func (service *Service) AdminMessage(ctx context.Context, id, body string) (domain.AdminReportDetail, error) { @@ -366,7 +367,7 @@ func (service *Service) AdminMessage(ctx context.Context, id, body string) (doma if err != nil { return domain.AdminReportDetail{}, err } - return adminDetail(report, payload, messages), nil + return service.adminDetail(report, payload, messages), nil } func (service *Service) addMessage(ctx context.Context, report domain.Report, author domain.MessageAuthor, body string, status *domain.ReportStatus) (domain.Report, error) { @@ -420,18 +421,19 @@ func (service *Service) openPrivatePayload(report domain.Report) (domain.Private return payload, nil } -func adminSummary(report domain.Report, payload domain.PrivatePayload) domain.AdminReportSummary { +func (service *Service) adminSummary(report domain.Report, payload domain.PrivatePayload) domain.AdminReportSummary { return domain.AdminReportSummary{ ID: report.ID, SupportCode: report.SupportCode, ProductID: report.ProductID, RequestType: report.RequestType, Status: report.Status, Source: payload.Source, Title: payload.Title, HasDiagnostics: report.DiagnosticObjectKey != nil, - CreatedAt: report.CreatedAt, UpdatedAt: report.UpdatedAt, RetentionUntil: report.RetentionUntil, + DiagnosticsState: service.diagnosticsState(report), + CreatedAt: report.CreatedAt, UpdatedAt: report.UpdatedAt, RetentionUntil: report.RetentionUntil, } } -func adminDetail(report domain.Report, payload domain.PrivatePayload, messages []domain.Message) domain.AdminReportDetail { +func (service *Service) adminDetail(report domain.Report, payload domain.PrivatePayload, messages []domain.Message) domain.AdminReportDetail { return domain.AdminReportDetail{ - AdminReportSummary: adminSummary(report, payload), Description: payload.Description, + AdminReportSummary: service.adminSummary(report, payload), Description: payload.Description, Contact: payload.Contact, Release: payload.Release, Messages: messages, } } @@ -610,3 +612,17 @@ func randomUUID() (string, error) { return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil } + +func (service *Service) diagnosticsState(report domain.Report) string { + if report.DiagnosticObjectKey == nil { + return "none" + } + exists, err := service.objects.Exists(*report.DiagnosticObjectKey) + if err != nil { + return "unknown" + } + if !exists { + return "unavailable" + } + return "available" +} diff --git a/internal/store/objects.go b/internal/store/objects.go index 664b067..87e593e 100644 --- a/internal/store/objects.go +++ b/internal/store/objects.go @@ -13,6 +13,7 @@ import ( var objectKeyPattern = regexp.MustCompile(`^[a-f0-9]{64}\.enc$`) type Objects interface { + Exists(key string) (bool, error) Put(key, reportID string, plaintext []byte) error Get(key, reportID string) ([]byte, error) Delete(key string) error @@ -113,3 +114,22 @@ func (objects *MemoryObjects) Delete(key string) error { delete(objects.Values, key) return nil } + +func (objects *FileObjects) Exists(key string) (bool, error) { + if !objectKeyPattern.MatchString(key) { + return false, errors.New("invalid private object key") + } + info, err := os.Stat(filepath.Join(objects.root, key)) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return info.Mode().IsRegular(), nil +} + +func (objects *MemoryObjects) Exists(key string) (bool, error) { + _, exists := objects.Values[key] + return exists, nil +} diff --git a/internal/web/admin.go b/internal/web/admin.go index 88831d1..be56fe4 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -306,6 +306,8 @@ func (server *Server) writeAdminError(response http.ResponseWriter, err error) { switch { case errors.Is(err, intake.ErrInvalid): writeProblem(response, http.StatusBadRequest, "invalid_request", "The admin request is invalid.") + case errors.Is(err, intake.ErrDiagnosticsUnavailable): + writeProblem(response, http.StatusServiceUnavailable, "diagnostics_unavailable", "The report is available, but its diagnostic archive is unavailable. Check private archive storage or request a new report.") case errors.Is(err, intake.ErrNotFound): writeProblem(response, http.StatusNotFound, "not_found", "The private report is not available.") default: diff --git a/internal/web/diagnostics_availability_test.go b/internal/web/diagnostics_availability_test.go new file mode 100644 index 0000000..e20a370 --- /dev/null +++ b/internal/web/diagnostics_availability_test.go @@ -0,0 +1,24 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/obiente/support/internal/intake" +) + +func TestMissingDiagnosticArchiveIsNotAMissingReport(t *testing.T) { + server := &Server{} + response := httptest.NewRecorder() + server.writeAdminError(response, intake.ErrDiagnosticsUnavailable) + if response.Code != http.StatusServiceUnavailable || !strings.Contains(response.Body.String(), "diagnostics_unavailable") { + t.Fatalf("unexpected response: %d", response.Code) + } + response = httptest.NewRecorder() + server.writeAdminError(response, intake.ErrNotFound) + if response.Code != http.StatusNotFound { + t.Fatalf("missing report status: %d", response.Code) + } +} diff --git a/openapi.yaml b/openapi.yaml index 9ed487b..86ab236 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -409,7 +409,11 @@ components: status: { $ref: "#/components/schemas/ReportStatus" } source: { enum: [web, app] } title: { type: string } - hasDiagnostics: { type: boolean } + hasDiagnostics: { type: boolean, description: Whether a diagnostic archive was attached to the report. } + diagnosticsState: + type: string + enum: [none, available, unavailable, unknown] + description: Current private object storage availability; unknown means the storage check failed. createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } retentionUntil: { type: string, format: date-time }