feat(history): scope export tasks to owners
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user