feat(history): scope export tasks to owners

This commit is contained in:
lingniu
2026-07-16 17:00:33 +08:00
parent a2722e4afd
commit 38b056f5f1
18 changed files with 528 additions and 100 deletions

View File

@@ -13,6 +13,10 @@ import (
"time"
)
func exportAdminContext() context.Context {
return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"})
}
func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
dir := t.TempDir()
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
@@ -31,11 +35,12 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
first.exportsMu.Unlock()
restarted := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
jobs := restarted.ListHistoryExports()
ctx := exportAdminContext()
jobs := restarted.ListHistoryExports(ctx)
if len(jobs) != 2 {
t.Fatalf("jobs=%+v", jobs)
}
path, _, err := restarted.HistoryExportFile("exp_completed")
path, _, err := restarted.HistoryExportFile(ctx, "exp_completed")
if err != nil || path != filePath {
t.Fatalf("completed export not restored: path=%q err=%v", path, err)
}
@@ -561,15 +566,16 @@ func containsInt(values []int, wanted int) bool {
func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) {
service := NewService(NewMockStore())
job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"})
ctx := exportAdminContext()
job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"})
if err != nil {
t.Fatalf("create export: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports()
jobs := service.ListHistoryExports(ctx)
if len(jobs) > 0 && jobs[0].ID == job.ID && jobs[0].Status == "completed" {
path, _, fileErr := service.HistoryExportFile(job.ID)
path, _, fileErr := service.HistoryExportFile(ctx, job.ID)
if fileErr != nil {
t.Fatalf("export file: %v", fileErr)
}
@@ -587,7 +593,99 @@ func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) {
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("export job did not complete: %+v", service.ListHistoryExports())
t.Fatalf("export job did not complete: %+v", service.ListHistoryExports(ctx))
}
func exportCustomerContext(subject, username, vin string) context.Context {
return WithPrincipal(context.Background(), Principal{
SubjectID: subject, Name: username, Username: username, Role: "customer", UserType: "customer", AuthProvider: "local",
CustomerRef: "customer-" + subject, VehicleVINs: []string{vin},
VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))}},
})
}
func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
vin := "LNXNEGRR7SR318212"
customerA := exportCustomerContext("101", "customer-a", vin)
customerB := exportCustomerContext("102", "customer-b", vin)
job, err := service.CreateHistoryExport(customerA, HistoryExportRequest{
Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv",
})
if err != nil {
t.Fatal(err)
}
if job.OwnerID != "local:subject:101" || job.OwnerUsername != "customer-a" || job.CustomerRef != "customer-101" || len(job.VehicleScopes) != 1 || job.VehicleScopes[0].VIN != vin {
t.Fatalf("owner/scope audit metadata missing: %+v", job)
}
if jobs := service.ListHistoryExports(customerB); len(jobs) != 0 {
t.Fatalf("customer B saw customer A export: %+v", jobs)
}
if _, _, err := service.HistoryExportFile(customerB, job.ID); err == nil {
t.Fatal("customer B should not download customer A export")
}
if jobs := service.ListHistoryExports(exportAdminContext()); len(jobs) != 1 || jobs[0].ID != job.ID {
t.Fatalf("admin should audit all jobs: %+v", jobs)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports(customerA)
if len(jobs) == 1 && jobs[0].Status == "completed" {
path, _, fileErr := service.HistoryExportFile(customerA, job.ID)
if fileErr != nil {
t.Fatal(fileErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
for _, evidence := range []string{"导出审计", "customer-a", "customer-101", "车辆 Scope", vin} {
if !strings.Contains(string(body), evidence) {
t.Fatalf("CSV missing audit evidence %q: %q", evidence, body[:min(len(body), 800)])
}
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA))
}
type revocableHistoryExportStore struct {
*MockStore
active bool
}
func (s *revocableHistoryExportStore) HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error) {
return s.active, nil
}
func TestCustomerHistoryExportStopsWhenAuthorizationIsRevoked(t *testing.T) {
store := &revocableHistoryExportStore{MockStore: NewMockStore(), active: false}
service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: t.TempDir()})
ctx := exportCustomerContext("101", "customer-a", "LNXNEGRR7SR318212")
job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{
Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv",
})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports(ctx)
if len(jobs) == 1 && jobs[0].Status == "failed" {
if !strings.Contains(jobs[0].Error, "授权已变化") {
t.Fatalf("unexpected revoke evidence: %+v", jobs[0])
}
if _, _, fileErr := service.HistoryExportFile(ctx, job.ID); fileErr == nil {
t.Fatal("revoked customer should not download export")
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("revoked export did not stop: %+v", service.ListHistoryExports(ctx))
}
type largeHistoryExportStore struct {
@@ -632,13 +730,14 @@ func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration)
dir := t.TempDir()
store := &largeHistoryExportStore{MockStore: NewMockStore(), total: total}
service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: dir})
job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"})
ctx := exportAdminContext()
job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
current := service.ListHistoryExports()[0]
current := service.ListHistoryExports(ctx)[0]
if current.Status == "failed" {
t.Fatalf("export failed: %+v", current)
}
@@ -646,7 +745,7 @@ func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration)
if current.RowCount != int(total) || current.ProcessedRows != total || current.TotalRows != total || current.FileSizeBytes == 0 || current.CompletedAt == "" {
t.Fatalf("incomplete evidence: %+v", current)
}
path, _, fileErr := service.HistoryExportFile(job.ID)
path, _, fileErr := service.HistoryExportFile(ctx, job.ID)
if fileErr != nil {
t.Fatal(fileErr)
}
@@ -664,7 +763,7 @@ func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration)
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("export timed out: %+v", service.ListHistoryExports())
t.Fatalf("export timed out: %+v", service.ListHistoryExports(ctx))
}
func TestRawMetricMetadataKeepsProtocolAndUnit(t *testing.T) {