Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions frontend/src/adminDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('./support')>(),
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: '<div />' } }] })
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()
})
})
1 change: 1 addition & 0 deletions frontend/src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/views/AdminReportsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ function readable(value: string): string {
<span class="report-list-meta"><b>{{ report.supportCode }}</b><span>{{ readable(report.status) }}</span></span>
<strong>{{ report.title }}</strong>
<span>{{ report.productId }} · {{ report.source }} · {{ formatDate(report.createdAt) }}</span>
<small v-if="report.hasDiagnostics">Diagnostic ZIP attached</small>
<small v-if="report.diagnosticsState === 'unavailable'">Diagnostic ZIP unavailable</small>
<small v-else-if="report.diagnosticsState === 'unknown'">Diagnostic storage could not be checked</small>
<small v-else-if="report.hasDiagnostics">Diagnostic ZIP attached</small>
</button>
<p v-if="reports.length === 0">No reports match this status.</p>
</section>
Expand Down Expand Up @@ -167,7 +169,9 @@ function readable(value: string): string {
<button class="primary" type="submit" :disabled="saving || !reply.trim()">{{ saving ? 'Sending...' : 'Send private message' }}</button>
</form>
</div>
<a v-if="selected.hasDiagnostics" class="secondary diagnostic-download" :href="adminDiagnosticsURL(selected.id)">Download diagnostic ZIP</a>
<a v-if="selected.hasDiagnostics && selected.diagnosticsState !== 'unavailable' && selected.diagnosticsState !== 'unknown'" class="secondary diagnostic-download" :href="adminDiagnosticsURL(selected.id)">Download diagnostic ZIP</a>
<p v-if="selected.diagnosticsState === 'unavailable'" role="status">The report is retained, but its diagnostic ZIP is unavailable. Check the private archive storage or request a new diagnostic report.</p>
<p v-else-if="selected.diagnosticsState === 'unknown'" role="status">Diagnostic storage could not be checked. Reload this report after storage is available.</p>
<p class="private-warning">Private report data must not be copied into a public issue without reviewing and removing personal information.</p>
</template>
<p v-else>Select a report to review its private details.</p>
Expand Down
23 changes: 12 additions & 11 deletions internal/domain/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions internal/intake/diagnostics_availability_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
48 changes: 32 additions & 16 deletions internal/intake/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand All @@ -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
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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"
}
20 changes: 20 additions & 0 deletions internal/store/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions internal/web/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions internal/web/diagnostics_availability_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 5 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading