feat(auth): enforce vehicle grant time boundaries
This commit is contained in:
@@ -320,6 +320,7 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local
|
||||
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
vehicles := []string{}
|
||||
vehicleGrants := []platform.VehicleGrant{}
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
@@ -339,17 +340,24 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
var validFrom time.Time
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&vin, &validFrom, &validTo); err != nil {
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
vehicles = append(vehicles, vin)
|
||||
grant := platform.VehicleGrant{VIN: vin, ValidFrom: validFrom}
|
||||
if validTo.Valid {
|
||||
grant.ValidTo = &validTo.Time
|
||||
}
|
||||
vehicleGrants = append(vehicleGrants, grant)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
@@ -358,7 +366,7 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
return platform.Principal{
|
||||
SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username,
|
||||
Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef,
|
||||
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles,
|
||||
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles, VehicleGrants: vehicleGrants,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -626,11 +634,56 @@ func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins [
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil {
|
||||
currentVINs := map[string]bool{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
currentVINs[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
selectedVINs := make(map[string]bool, len(vins))
|
||||
for _, vin := range vins {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?)`, userID, vin, actor); err != nil {
|
||||
selectedVINs[vin] = true
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL`, actor, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(vins)), ",")
|
||||
args := make([]any, 0, len(vins)+1)
|
||||
args = append(args, userID)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
historyArgs := append([]any{actor}, args...)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (`+placeholders+`)`, historyArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (`+placeholders+`)`, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, vin := range vins {
|
||||
if currentVINs[vin] && selectedVINs[vin] {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,valid_from,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`, userID, vin, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`, userID, vin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).
|
||||
WithArgs(uint64(7), "monitor", "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)).
|
||||
WithArgs("admin-a", uint64(7), "VIN002").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).
|
||||
WithArgs(uint64(7), "VIN002").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle(user_id,vin,valid_from,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`)).
|
||||
WithArgs(uint64(7), "VIN002", "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`)).
|
||||
WithArgs(uint64(7), "VIN002").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN002"}, "admin-a"); err != nil {
|
||||
t.Fatalf("replaceGrants failed: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceGrantsPreservesUnchangedVehicleStartTime(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).
|
||||
WithArgs(uint64(7), "monitor", "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)).
|
||||
WithArgs("admin-a", uint64(7), "VIN001").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).
|
||||
WithArgs(uint64(7), "VIN001").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN001"}, "admin-a"); err != nil {
|
||||
t.Fatalf("replaceGrants failed: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,8 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er
|
||||
switch {
|
||||
case clientErr.Code == "VEHICLE_PERMISSION_DENIED":
|
||||
status = http.StatusForbidden
|
||||
case strings.HasPrefix(clientErr.Code, "HISTORY_SCOPE_"), clientErr.Code == "HISTORY_BEFORE_AUTHORIZATION":
|
||||
status = http.StatusForbidden
|
||||
case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"):
|
||||
status = http.StatusNotFound
|
||||
case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -455,6 +457,7 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
@@ -517,6 +520,7 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
@@ -552,6 +556,7 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", query.Get("scopeVehicleFrom"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
@@ -700,6 +705,40 @@ func appendVINListFilter(where []string, args []any, column string, raw string)
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
func appendVehicleDateScope(where []string, args []any, vinColumn, dateColumn, raw string) ([]string, []any) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return where, args
|
||||
}
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || len(parsed) == 0 {
|
||||
return append(where, "1 = 0"), args
|
||||
}
|
||||
cutoffs := make(map[string]string, len(parsed))
|
||||
for vin, cutoff := range parsed {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
cutoff = strings.TrimSpace(cutoff)
|
||||
if vin == "" || cutoff == "" {
|
||||
continue
|
||||
}
|
||||
cutoffs[vin] = cutoff
|
||||
}
|
||||
if len(cutoffs) == 0 {
|
||||
return append(where, "1 = 0"), args
|
||||
}
|
||||
vins := make([]string, 0, len(cutoffs))
|
||||
for vin := range cutoffs {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
predicates := make([]string, 0, len(vins))
|
||||
for _, vin := range vins {
|
||||
predicates = append(predicates, "("+vinColumn+" = ? AND "+dateColumn+" >= ?)")
|
||||
args = append(args, vin, cutoffs[vin])
|
||||
}
|
||||
return append(where, "("+strings.Join(predicates, " OR ")+")"), args
|
||||
}
|
||||
|
||||
func buildLimitOffset(query url.Values) (int, int) {
|
||||
return parseSQLPageSize(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
package platform
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VehicleGrant struct {
|
||||
VIN string `json:"-"`
|
||||
ValidFrom time.Time `json:"-"`
|
||||
ValidTo *time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleGrants []VehicleGrant `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
}
|
||||
|
||||
func (p Principal) Clone() Principal {
|
||||
p.MenuKeys = append([]string(nil), p.MenuKeys...)
|
||||
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
|
||||
p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...)
|
||||
p.VehicleCount = len(p.VehicleVINs)
|
||||
return p
|
||||
}
|
||||
@@ -48,6 +60,16 @@ func (p Principal) CanVIN(vin string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p Principal) VehicleGrant(vin string) (VehicleGrant, bool) {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
for _, grant := range p.VehicleGrants {
|
||||
if strings.ToUpper(strings.TrimSpace(grant.VIN)) == vin {
|
||||
return grant, true
|
||||
}
|
||||
}
|
||||
return VehicleGrant{}, false
|
||||
}
|
||||
|
||||
type principalContextKey struct{}
|
||||
|
||||
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
|
||||
|
||||
@@ -331,6 +331,42 @@ func TestMileageQueriesFilterExactVINSelection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesApplyPerVehicleAuthorizationDates(t *testing.T) {
|
||||
query := url.Values{
|
||||
"scopeVins": {"VIN001,VIN002"},
|
||||
"scopeVehicleFrom": {`{"vin002":"2026-07-12","VIN001":"2026-07-11"}`},
|
||||
}
|
||||
for name, built := range map[string]SQLQuery{
|
||||
"daily": buildDailyMileageSQL(query),
|
||||
"summary": buildMileageSummarySQL(query),
|
||||
"statistics": buildMileageStatisticsSummarySQL(query),
|
||||
} {
|
||||
for _, want := range []string{
|
||||
"(m.vin = ? AND m.stat_date >= ?)",
|
||||
"VIN001 2026-07-11",
|
||||
"VIN002 2026-07-12",
|
||||
} {
|
||||
text := built.Text
|
||||
if strings.HasPrefix(want, "VIN") {
|
||||
text = fmt.Sprint(built.Args)
|
||||
}
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("%s authorization scope missing %q: %s args=%#v", name, want, built.Text, built.Args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesFailClosedForMalformedAuthorizationDates(t *testing.T) {
|
||||
built := buildDailyMileageSQL(url.Values{
|
||||
"scopeVins": {"VIN001"},
|
||||
"scopeVehicleFrom": {"not-json"},
|
||||
})
|
||||
if !strings.Contains(built.Text, "1 = 0") || !strings.Contains(built.CountText, "1 = 0") {
|
||||
t.Fatalf("malformed authorization scope must fail closed: %s / %s", built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *testing.T) {
|
||||
query := url.Values{"vehicleScope": {"bound"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
for name, built := range map[string]SQLQuery{
|
||||
|
||||
@@ -920,6 +920,16 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
rawQuery.Protocol = protocol
|
||||
qualityQuery.Set("protocol", protocol)
|
||||
}
|
||||
historyQuery, err = applyPrincipalHistoryTimeScope(ctx, queryVIN, historyQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
rawQuery.DateFrom = historyQuery.Get("dateFrom")
|
||||
rawQuery.DateTo = historyQuery.Get("dateTo")
|
||||
mileageQuery, err = applyPrincipalVehicleScope(ctx, mileageQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
realtimeSummary, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {queryVIN}, "limit": {"1"}})
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
@@ -1273,20 +1283,21 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string {
|
||||
}
|
||||
|
||||
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
||||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
|
||||
for index := range statuses {
|
||||
protocol := statuses[index].Protocol
|
||||
if protocol == "" {
|
||||
continue
|
||||
}
|
||||
if !statuses[index].HasHistory {
|
||||
query := url.Values{"vin": {vin}, "protocol": {protocol}, "limit": {"1"}}
|
||||
if !statuses[index].HasHistory && scopeErr == nil {
|
||||
query := url.Values{"vin": {vin}, "protocol": {protocol}, "dateFrom": {scopedWindow.Get("dateFrom")}, "limit": {"1"}}
|
||||
if page, err := s.store.HistoryLocationsFromTDengine(ctx, query); err == nil && len(page.Items) > 0 {
|
||||
statuses[index].HasHistory = true
|
||||
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||
}
|
||||
}
|
||||
if !statuses[index].HasRaw {
|
||||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, Limit: 1})
|
||||
if !statuses[index].HasRaw && scopeErr == nil {
|
||||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1})
|
||||
if err == nil && len(page.Items) > 0 {
|
||||
statuses[index].HasRaw = true
|
||||
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||
@@ -1347,6 +1358,10 @@ func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[
|
||||
if err != nil {
|
||||
return Page[HistoryLocationRow]{}, err
|
||||
}
|
||||
resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery)
|
||||
if err != nil {
|
||||
return Page[HistoryLocationRow]{}, err
|
||||
}
|
||||
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
@@ -1369,6 +1384,10 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
if err != nil {
|
||||
return TrackPlaybackResponse{}, err
|
||||
}
|
||||
resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery)
|
||||
if err != nil {
|
||||
return TrackPlaybackResponse{}, err
|
||||
}
|
||||
resolvedQuery.Set("limit", "5000")
|
||||
resolvedQuery.Set("offset", "0")
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
@@ -1401,7 +1420,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
Quality: quality,
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
result.Coverage = trackCoverage(query, page.Total, len(rawPoints), len(points), 0, result.Truncated, false)
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
if len(points) == 0 {
|
||||
return result, nil
|
||||
@@ -1440,7 +1459,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
for index := range result.Stops {
|
||||
result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes)
|
||||
}
|
||||
result.Coverage = trackCoverage(query, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
result.Coverage.ActualStart = result.Summary.StartTime
|
||||
result.Coverage.ActualEnd = result.Summary.EndTime
|
||||
@@ -1538,11 +1557,15 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
|
||||
if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil {
|
||||
return LatestTelemetryResponse{}, err
|
||||
}
|
||||
scopedWindow, err := applyPrincipalHistoryTimeScope(ctx, resolvedVIN, url.Values{})
|
||||
if err != nil {
|
||||
return LatestTelemetryResponse{}, err
|
||||
}
|
||||
rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols))
|
||||
catalogResult := make(chan latestTelemetryCatalogResult, 1)
|
||||
for _, protocol := range canonicalVehicleProtocols {
|
||||
go func(protocol string) {
|
||||
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, IncludeFields: true, Limit: 5, SkipCount: true})
|
||||
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
|
||||
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
|
||||
}(protocol)
|
||||
}
|
||||
@@ -1981,7 +2004,17 @@ func (s *Service) HistorySeries(ctx context.Context, query url.Values) (HistoryS
|
||||
if resolveErr != nil {
|
||||
return HistorySeriesResponse{}, resolveErr
|
||||
}
|
||||
buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: dateFrom, DateTo: dateTo, GrainSeconds: grainSeconds})
|
||||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||||
return HistorySeriesResponse{}, err
|
||||
}
|
||||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{
|
||||
"dateFrom": {dateFrom},
|
||||
"dateTo": {dateTo},
|
||||
})
|
||||
if scopeErr != nil {
|
||||
return HistorySeriesResponse{}, scopeErr
|
||||
}
|
||||
buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), GrainSeconds: grainSeconds})
|
||||
if aggregateErr != nil {
|
||||
return HistorySeriesResponse{}, aggregateErr
|
||||
}
|
||||
@@ -2132,6 +2165,10 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -2150,7 +2187,7 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), IncludeFields: true, Limit: fetchLimit})
|
||||
page, err := s.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), IncludeFields: true, Limit: fetchLimit})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -2170,6 +2207,9 @@ func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword s
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, err := s.store.DailyMileage(ctx, resolvedQuery)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -3195,6 +3235,18 @@ func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawF
|
||||
if resolvedVIN != "" {
|
||||
query.VIN = resolvedVIN
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, query.VIN); err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
scoped, err := applyPrincipalHistoryTimeScope(ctx, query.VIN, url.Values{
|
||||
"dateFrom": {query.DateFrom},
|
||||
"dateTo": {query.DateTo},
|
||||
})
|
||||
if err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
query.DateFrom = scoped.Get("dateFrom")
|
||||
query.DateTo = scoped.Get("dateTo")
|
||||
return s.store.RawFrames(ctx, query)
|
||||
}
|
||||
|
||||
@@ -3211,6 +3263,9 @@ func (s *Service) MileageSummary(ctx context.Context, query url.Values) (Mileage
|
||||
if err != nil {
|
||||
return MileageSummary{}, err
|
||||
}
|
||||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||
return MileageSummary{}, err
|
||||
}
|
||||
return s.store.MileageSummary(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
@@ -3219,6 +3274,9 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
@@ -3235,6 +3293,9 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
@@ -3730,9 +3791,99 @@ func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Valu
|
||||
} else {
|
||||
next.Set("scopeVins", strings.Join(principal.VehicleVINs, ","))
|
||||
}
|
||||
validFromByVIN := make(map[string]string, len(principal.VehicleGrants))
|
||||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
for _, grant := range principal.VehicleGrants {
|
||||
vin := strings.ToUpper(strings.TrimSpace(grant.VIN))
|
||||
if vin == "" || grant.ValidFrom.IsZero() {
|
||||
continue
|
||||
}
|
||||
validFrom := grant.ValidFrom.In(shanghai)
|
||||
firstFullDay := time.Date(validFrom.Year(), validFrom.Month(), validFrom.Day(), 0, 0, 0, 0, shanghai)
|
||||
if !validFrom.Equal(firstFullDay) {
|
||||
firstFullDay = firstFullDay.AddDate(0, 0, 1)
|
||||
}
|
||||
validFromByVIN[vin] = firstFullDay.Format("2006-01-02")
|
||||
}
|
||||
encoded, err := json.Marshal(validFromByVIN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next.Set("scopeVehicleFrom", string(encoded))
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.Values) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return next, nil
|
||||
}
|
||||
grant, ok := principal.VehicleGrant(vin)
|
||||
if !ok || grant.ValidFrom.IsZero() {
|
||||
return nil, clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"}
|
||||
}
|
||||
cutoff := grant.ValidFrom
|
||||
var requestedStart time.Time
|
||||
if value := strings.TrimSpace(next.Get("dateTo")); value != "" {
|
||||
end, parsed := parseTrackRequestTime(value)
|
||||
if !parsed {
|
||||
return nil, clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"}
|
||||
}
|
||||
if !end.After(cutoff) {
|
||||
return nil, clientError{Code: "HISTORY_BEFORE_AUTHORIZATION", Message: "所选时间范围早于该车辆的授权开始时间"}
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(next.Get("dateFrom")); value != "" {
|
||||
start, parsed := parseTrackRequestTime(value)
|
||||
if !parsed {
|
||||
return nil, clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"}
|
||||
}
|
||||
requestedStart = start
|
||||
}
|
||||
if grant.ValidTo != nil && !grant.ValidTo.IsZero() {
|
||||
if !requestedStart.IsZero() && !requestedStart.Before(*grant.ValidTo) {
|
||||
return nil, clientError{Code: "HISTORY_AFTER_AUTHORIZATION", Message: "所选时间范围晚于该车辆的授权结束时间"}
|
||||
}
|
||||
if value := strings.TrimSpace(next.Get("dateTo")); value == "" {
|
||||
next.Set("dateTo", grant.ValidTo.Format(time.RFC3339))
|
||||
} else if end, _ := parseTrackRequestTime(value); end.After(*grant.ValidTo) {
|
||||
next.Set("dateTo", grant.ValidTo.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
if !requestedStart.IsZero() && !requestedStart.Before(cutoff) {
|
||||
return next, nil
|
||||
}
|
||||
next.Set("dateFrom", cutoff.Format(time.RFC3339))
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return nil
|
||||
}
|
||||
requested := make([]string, 0)
|
||||
for _, raw := range []string{query.Get("vin"), query.Get("vins")} {
|
||||
for _, vin := range strings.Split(raw, ",") {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if vin != "" {
|
||||
requested = append(requested, vin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(requested) == 0 {
|
||||
requested = principal.VehicleVINs
|
||||
}
|
||||
for _, vin := range requested {
|
||||
grant, exists := principal.VehicleGrant(vin)
|
||||
if !exists || grant.ValidFrom.IsZero() {
|
||||
return clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizeVehicleVIN(ctx context.Context, vin string) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
|
||||
@@ -48,11 +48,14 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
|
||||
|
||||
type countingStore struct {
|
||||
*MockStore
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
overviewBatchCalls int
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
overviewBatchCalls int
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
lastHistoryQuery url.Values
|
||||
lastDailyMileageQuery url.Values
|
||||
lastMileageStatisticsQuery url.Values
|
||||
}
|
||||
|
||||
func newCountingStore() *countingStore {
|
||||
@@ -71,6 +74,21 @@ func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (
|
||||
return s.MockStore.VehicleRealtime(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
s.lastHistoryQuery = cloneValues(query)
|
||||
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
||||
s.lastDailyMileageQuery = cloneValues(query)
|
||||
return s.MockStore.DailyMileage(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
s.lastMileageStatisticsQuery = cloneValues(query)
|
||||
return s.MockStore.MileageStatistics(ctx, query)
|
||||
}
|
||||
|
||||
func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
@@ -88,6 +106,86 @@ func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHistoryIsClampedToVehicleGrantStart(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
principal := Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||
}
|
||||
ctx := WithPrincipal(context.Background(), principal)
|
||||
_, err := service.HistoryLocations(ctx, url.Values{
|
||||
"vin": {"LB9A32A24R0LS1426"},
|
||||
"dateFrom": {"2026-07-01T00:00:00+08:00"},
|
||||
"dateTo": {"2026-07-11T00:00:00+08:00"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("history query failed: %v", err)
|
||||
}
|
||||
if got := store.lastHistoryQuery.Get("dateFrom"); got != validFrom.Format(time.RFC3339) {
|
||||
t.Fatalf("dateFrom=%q want=%q", got, validFrom.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHistoryBeforeGrantIsForbidden(t *testing.T) {
|
||||
service := NewService(newCountingStore())
|
||||
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||
})
|
||||
_, err := service.HistoryLocations(ctx, url.Values{
|
||||
"vin": {"LB9A32A24R0LS1426"},
|
||||
"dateFrom": {"2026-07-01T00:00:00+08:00"},
|
||||
"dateTo": {"2026-07-10T12:34:56+08:00"},
|
||||
})
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" {
|
||||
t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageStartsAtFirstCompleteAuthorizedDay(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||
})
|
||||
if _, err := service.DailyMileage(ctx, url.Values{
|
||||
"vins": {"LB9A32A24R0LS1426"},
|
||||
"dateFrom": {"2026-07-01"},
|
||||
"dateTo": {"2026-07-15"},
|
||||
}); err != nil {
|
||||
t.Fatalf("daily mileage failed: %v", err)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("scopeVehicleFrom"); got != `{"LB9A32A24R0LS1426":"2026-07-11"}` {
|
||||
t.Fatalf("scopeVehicleFrom=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageFailsClosedWithoutGrantTime(t *testing.T) {
|
||||
service := NewService(newCountingStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
})
|
||||
_, err := service.MileageStatistics(ctx, url.Values{
|
||||
"vins": {"LB9A32A24R0LS1426"},
|
||||
"dateFrom": {"2026-07-01"},
|
||||
"dateTo": {"2026-07-15"},
|
||||
})
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "HISTORY_SCOPE_UNAVAILABLE" {
|
||||
t.Fatalf("expected HISTORY_SCOPE_UNAVAILABLE, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
s.overviewBatchCalls++
|
||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||
|
||||
Reference in New Issue
Block a user