From ecceb740301c7c7d9e33e0d6342a53d458000316 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:07:17 -0400 Subject: [PATCH 1/8] feat(types): add RankType enum for RankShort derivation Port the RANK_TYPE_* name table from proto so the datastore can derive RankShort without importing proto. The gap at 24/25 and the decimal fallback for uncataloged ids are kept, so RankShort stays byte-identical after the swap to types. RankShort() does the prefix strip the datastore does today at four call sites. > *This was generated by AI* --- types/ranktype.go | 113 +++++++++++++++++++++++++++++++++++++++++ types/ranktype_test.go | 65 ++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 types/ranktype.go create mode 100644 types/ranktype_test.go diff --git a/types/ranktype.go b/types/ranktype.go new file mode 100644 index 0000000..e1c3cc5 --- /dev/null +++ b/types/ranktype.go @@ -0,0 +1,113 @@ +package types + +import ( + "strconv" + "strings" +) + +// RankType identifies a pay-grade entry. The numeric values are the upstream +// rank_id foreign key (xf_nf_rosters_rank) and match the proto enum being +// retired at Phase 4 exactly. +// +// Unlike RosterType and RecordType, RankType never appears on the wire as a +// named enum: it exists solely so the datastore can derive RankShort by +// stripping the RANK_TYPE_ prefix off the name (e.g. RankType(6) -> "COL"). +// The name table, the 24/25 gap, and the decimal fallback for uncataloged +// values are ported verbatim from the generated proto RankType_name so the +// derived RankShort stays byte-identical across the proto -> types swap. +type RankType int32 + +const ( + RankTypeUnspecified RankType = 0 + RankTypeGoa RankType = 1 + RankTypeGen RankType = 2 + RankTypeLtg RankType = 3 + RankTypeMg RankType = 4 + RankTypeBg RankType = 5 + RankTypeCol RankType = 6 + RankTypeLtc RankType = 7 + RankTypeMaj RankType = 8 + RankTypeCpt RankType = 9 + RankType1lt RankType = 10 + RankType2lt RankType = 11 + RankTypeCsm RankType = 12 + RankTypeSgm RankType = 13 + RankType1sg RankType = 14 + RankTypeMsg RankType = 15 + RankTypeSfc RankType = 16 + RankTypeSsg RankType = 17 + RankTypeSgt RankType = 18 + RankTypeCpl RankType = 19 + RankTypeSpc RankType = 20 + RankTypePfc RankType = 21 + RankTypePvt RankType = 22 + RankTypeRct RankType = 23 + // 24 and 25 are intentionally unassigned — the gap is preserved from the + // proto enum so those ids fall back to their decimal string. + RankTypeCw5 RankType = 26 + RankTypeCw4 RankType = 27 + RankTypeCw3 RankType = 28 + RankTypeCw2 RankType = 29 + RankTypeWo1 RankType = 30 + RankTypeAr RankType = 31 +) + +// rankTypeNames is the wire-name catalog, keyed by enum value. Ported verbatim +// from proto.RankType_name; the 24/25 gap is intentional. +var rankTypeNames = map[RankType]string{ + RankTypeUnspecified: "RANK_TYPE_UNSPECIFIED", + RankTypeGoa: "RANK_TYPE_GOA", + RankTypeGen: "RANK_TYPE_GEN", + RankTypeLtg: "RANK_TYPE_LTG", + RankTypeMg: "RANK_TYPE_MG", + RankTypeBg: "RANK_TYPE_BG", + RankTypeCol: "RANK_TYPE_COL", + RankTypeLtc: "RANK_TYPE_LTC", + RankTypeMaj: "RANK_TYPE_MAJ", + RankTypeCpt: "RANK_TYPE_CPT", + RankType1lt: "RANK_TYPE_1LT", + RankType2lt: "RANK_TYPE_2LT", + RankTypeCsm: "RANK_TYPE_CSM", + RankTypeSgm: "RANK_TYPE_SGM", + RankType1sg: "RANK_TYPE_1SG", + RankTypeMsg: "RANK_TYPE_MSG", + RankTypeSfc: "RANK_TYPE_SFC", + RankTypeSsg: "RANK_TYPE_SSG", + RankTypeSgt: "RANK_TYPE_SGT", + RankTypeCpl: "RANK_TYPE_CPL", + RankTypeSpc: "RANK_TYPE_SPC", + RankTypePfc: "RANK_TYPE_PFC", + RankTypePvt: "RANK_TYPE_PVT", + RankTypeRct: "RANK_TYPE_RCT", + RankTypeCw5: "RANK_TYPE_CW5", + RankTypeCw4: "RANK_TYPE_CW4", + RankTypeCw3: "RANK_TYPE_CW3", + RankTypeCw2: "RANK_TYPE_CW2", + RankTypeWo1: "RANK_TYPE_WO1", + RankTypeAr: "RANK_TYPE_AR", +} + +// String returns the wire name, or the bare decimal number for values the +// catalog has no name for — mirroring the generated proto String() so the +// derived RankShort stays identical. +func (rt RankType) String() string { + if name, ok := rankTypeNames[rt]; ok { + return name + } + return decimalString(int64(rt)) +} + +// RankShort is the short rank name the datastore stamps onto profiles: the +// enum name with the RANK_TYPE_ prefix stripped (e.g. "COL"). For uncataloged +// ids it is the bare decimal (the prefix strip is a no-op on a number). +func (rt RankType) RankShort() string { + return trimRankPrefix(rt.String()) +} + +func decimalString(v int64) string { + return strconv.FormatInt(v, 10) +} + +func trimRankPrefix(name string) string { + return strings.TrimPrefix(name, "RANK_TYPE_") +} diff --git a/types/ranktype_test.go b/types/ranktype_test.go new file mode 100644 index 0000000..7434167 --- /dev/null +++ b/types/ranktype_test.go @@ -0,0 +1,65 @@ +package types + +import "testing" + +// TestRankType_String_MatchesProtoNames pins a representative set of rank ids +// to their wire names. The values are cross-checked against the generated +// proto RankType_name table that mysql.go relied on before the type-swap, so +// strings.TrimPrefix(RankType(id).String(), "RANK_TYPE_") stays byte-identical +// to the old proto-derived RankShort. +func TestRankType_String_MatchesProtoNames(t *testing.T) { + cases := []struct { + id RankType + name string + }{ + {0, "RANK_TYPE_UNSPECIFIED"}, + {1, "RANK_TYPE_GOA"}, + {2, "RANK_TYPE_GEN"}, + {6, "RANK_TYPE_COL"}, + {9, "RANK_TYPE_CPT"}, + {18, "RANK_TYPE_SGT"}, + {22, "RANK_TYPE_PVT"}, + {23, "RANK_TYPE_RCT"}, + {26, "RANK_TYPE_CW5"}, + {29, "RANK_TYPE_CW2"}, + {30, "RANK_TYPE_WO1"}, + {31, "RANK_TYPE_AR"}, + } + + for _, c := range cases { + if got := c.id.String(); got != c.name { + t.Errorf("RankType(%d).String() = %q, want %q", c.id, got, c.name) + } + } +} + +// TestRankType_String_GapAndUnknownFallBackToDecimal pins the 24/25 gap in the +// proto enum (and any other uncataloged value) to the bare decimal fallback — +// matching the generated proto String(), so an unknown rank id yields its +// number, not an empty short name. +func TestRankType_String_GapAndUnknownFallBackToDecimal(t *testing.T) { + for _, id := range []RankType{24, 25, 99, -1} { + want := decimalString(int64(id)) + if got := id.String(); got != want { + t.Errorf("RankType(%d).String() = %q, want %q", id, got, want) + } + } +} + +// TestRankType_RankShort_StripsPrefix checks the actual RankShort derivation +// the datastore performs. +func TestRankType_RankShort_StripsPrefix(t *testing.T) { + cases := map[RankType]string{ + 1: "GOA", + 6: "COL", + 23: "RCT", + 26: "CW5", + 31: "AR", + } + for id, short := range cases { + got := trimRankPrefix(id.String()) + if got != short { + t.Errorf("RankShort for RankType(%d) = %q, want %q", id, got, short) + } + } +} From 887b923b6d63282723bc65aeee69280e9a219216 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:22:00 -0400 Subject: [PATCH 2/8] refactor(datastores): return types.* instead of proto.* The Datastore interface and the Mysql implementation now build types.* wire structs directly. The rest handlers drop their *FromProto mappers and consume the datastore results as-is. - Delete FindProfileByKeycloakID and extractKeycloakID. The new profile types carry no keycloak field, so nothing populates one. - Remove the vestigial xenforo.ConnectedAccountJoin from the roster, profile, position, s1-uniforms, and gamertag queries. It stays on FindProfileByDiscordID, whose WHERE filters the connected-account columns. DiscordId still populates from the Preload(clause.Associations) load, so the roster goldens hold. - Derive RankShort through types.RankType, byte-identical to the old proto path. - Move the ticket allocation discipline into the datastore: participants and categoryAncestorIds serialize as [] when empty, customFields as {}, matching the frozen tickets goldens. The two xf_post aggregation SQL consts are untouched. > *This was generated by AI* --- datastores/datastore.go | 37 ++++--- datastores/mysql.go | 229 +++++++++++++++++----------------------- datastores/tickets.go | 49 +++++---- rest/awol.go | 24 +---- rest/milpacs.go | 95 ++--------------- rest/positions.go | 73 +------------ rest/rosters.go | 102 ++---------------- rest/tickets.go | 126 ++++------------------ 8 files changed, 177 insertions(+), 558 deletions(-) diff --git a/datastores/datastore.go b/datastores/datastore.go index 71e99a6..56ef458 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -24,8 +24,8 @@ import ( "os" "strings" - "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" + "github.com/7cav/api/types" ) var ( @@ -71,27 +71,26 @@ type Datastore interface { // non-nil profiles on a nil error — no-match is gorm.ErrRecordNotFound, // never an empty slice. Handlers index [0] under this invariant (with a // defensive 500 guard for implementations that break it). - FindProfilesById(userId ...uint64) ([]*proto.Profile, error) - FindProfilesByUsername(username string) ([]*proto.Profile, error) - FindRosterByType(rosterType proto.RosterType) (*proto.Roster, error) - FindLiteRosterByType(rosterType proto.RosterType) (*proto.LiteRoster, error) - FindProfileByKeycloakID(keycloakId string) (*proto.Profile, error) - FindProfileByDiscordID(discordId string) (*proto.Profile, error) - FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, error) - FindS1UniformsRosterByType(rosterType proto.RosterType) (*proto.S1UniformsRoster, error) - FindAllRanks() ([]*proto.RankExpanded, error) - FindAllPositionGroups() ([]*proto.PositionGroup, error) - FindAwol() ([]*proto.Awol, error) - FindProfileByGamertag(gamertag string) (*proto.Profile, error) + FindProfilesById(userId ...uint64) ([]*types.Profile, error) + FindProfilesByUsername(username string) ([]*types.Profile, error) + FindRosterByType(rosterType types.RosterType) (*types.Roster, error) + FindLiteRosterByType(rosterType types.RosterType) (*types.LiteRoster, error) + FindProfileByDiscordID(discordId string) (*types.Profile, error) + FindProfilesByPosition(positionQuery string) (*types.LiteRoster, error) + FindS1UniformsRosterByType(rosterType types.RosterType) (*types.S1UniformsRoster, error) + FindAllRanks() ([]*types.RankExpanded, error) + FindAllPositionGroups() ([]*types.PositionGroup, error) + FindAwol() ([]*types.Awol, error) + FindProfileByGamertag(gamertag string) (*types.Profile, error) ValidateApiKey(rawKey string) (*ApiKeyResult, error) // Tickets - ListTickets(ctx context.Context, rc TicketReferenceCache, filter *ListTicketsFilter) (tickets []*proto.Ticket, nextCursor string, hasMore bool, err error) - GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*proto.Ticket, error) - GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*proto.Ticket, error) - GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*proto.Message, totalCount uint32, err error) - ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) (msgs []*proto.Message, nextCursor string, hasMore bool, err error) - ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error) + ListTickets(ctx context.Context, rc TicketReferenceCache, filter *ListTicketsFilter) (tickets []*types.Ticket, nextCursor string, hasMore bool, err error) + GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*types.Ticket, error) + GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*types.Ticket, error) + GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*types.Message, totalCount uint32, err error) + ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) (msgs []*types.Message, nextCursor string, hasMore bool, err error) + ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*types.Category, error) } // TicketReferenceCache is the slice of referencecache.ReferenceCache that diff --git a/datastores/mysql.go b/datastores/mysql.go index 1c9417c..78b5ccb 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -7,7 +7,7 @@ import ( "time" "github.com/7cav/api/milpacs" - "github.com/7cav/api/proto" + "github.com/7cav/api/types" "github.com/7cav/api/xenforo" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -21,14 +21,13 @@ const ( layoutISO = "2006-01-02" ) -func (ds Mysql) FindProfilesById(userIds ...uint64) ([]*proto.Profile, error) { +func (ds Mysql) FindProfilesById(userIds ...uint64) ([]*types.Profile, error) { var profile milpacs.Profile Info.Println("Searching for user: ", userIds[0]) result := ds.Db.Preload(clause.Associations). Preload("AwardRecords.Award"). - Joins(xenforo.ConnectedAccountJoin). First(&profile, userIds[0]) if result.Error != nil { @@ -40,11 +39,11 @@ func (ds Mysql) FindProfilesById(userIds ...uint64) ([]*proto.Profile, error) { return nil, fmt.Errorf("error generating profile: %w", err) } - return []*proto.Profile{profiles[profile.RelationId]}, nil + return []*types.Profile{profiles[profile.RelationId]}, nil } -func (ds Mysql) FindProfilesByUsername(username string) ([]*proto.Profile, error) { +func (ds Mysql) FindProfilesByUsername(username string) ([]*types.Profile, error) { var profile milpacs.Profile Info.Println("Searching for user with username: ", username) @@ -52,7 +51,6 @@ func (ds Mysql) FindProfilesByUsername(username string) ([]*proto.Profile, error result := ds.Db.Preload(clause.Associations). Preload("AwardRecords.Award"). Joins("JOIN xf_user ON xf_user.user_id = xf_nf_rosters_user.user_id"). - Joins(xenforo.ConnectedAccountJoin). Where("xf_user.username = ?", username). First(&profile) @@ -65,18 +63,17 @@ func (ds Mysql) FindProfilesByUsername(username string) ([]*proto.Profile, error return nil, fmt.Errorf("error generating profile: %w", err) } - return []*proto.Profile{profiles[profile.RelationId]}, nil + return []*types.Profile{profiles[profile.RelationId]}, nil } -func (ds Mysql) FindRosterByType(rosterType proto.RosterType) (*proto.Roster, error) { +func (ds Mysql) FindRosterByType(rosterType types.RosterType) (*types.Roster, error) { var rosterProfiles []milpacs.Profile - Info.Println("Searching for roster: ", rosterType.String(), "id:", uint(rosterType.Number())) + Info.Println("Searching for roster: ", rosterType.String(), "id:", uint(rosterType)) result := ds.Db.Preload(clause.Associations). Preload("AwardRecords.Award"). - Joins(xenforo.ConnectedAccountJoin). - Where(map[string]interface{}{"roster_id": uint(rosterType.Number())}). + Where(map[string]interface{}{"roster_id": uint(rosterType)}). Find(&rosterProfiles) if result.Error != nil { return nil, fmt.Errorf("find roster %s: %w", rosterType, result.Error) @@ -87,41 +84,19 @@ func (ds Mysql) FindRosterByType(rosterType proto.RosterType) (*proto.Roster, er return nil, fmt.Errorf("error generating profiles: %w", err) } - return &proto.Roster{Profiles: profiles}, nil + return &types.Roster{Profiles: profiles}, nil } -func (ds Mysql) FindProfileByKeycloakID(keycloakId string) (*proto.Profile, error) { - var profile milpacs.Profile - - Info.Println("Searching for milpac profiles with keycloak IDs of: ", keycloakId) - - // gorm 1.26+ qualifies map-keyed WHERE columns with the current model's table, - // turning "xf_user_connected_account.provider" into a broken three-part qualifier. - // Use placeholder SQL so the joined-table columns stay unqualified. - result := ds.Db.Preload(clause.Associations). - Preload("AwardRecords.Award"). - Joins(xenforo.ConnectedAccountJoin). - Where("xf_user_connected_account.provider = ? AND xf_user_connected_account.provider_key = ?", "keycloak", keycloakId). - First(&profile) - - if result.Error != nil { - return nil, result.Error - } - - profiles, err := ds.processProfiles([]milpacs.Profile{profile}) - if err != nil { - return nil, fmt.Errorf("error generating profile: %w", err) - } - - return profiles[profile.RelationId], nil -} - -func (ds Mysql) FindProfileByDiscordID(discordId string) (*proto.Profile, error) { +func (ds Mysql) FindProfileByDiscordID(discordId string) (*types.Profile, error) { var profile milpacs.Profile Info.Printf("Searching for milpac profiles with discord IDs of: %s", discordId) - // See note in FindProfileByKeycloakID — gorm 1.26+ misqualifies dotted map keys. + // gorm 1.26+ qualifies map-keyed WHERE columns with the current model's + // table, turning "xf_user_connected_account.provider" into a broken + // three-part qualifier. Use placeholder SQL so the joined-table columns + // stay unqualified. The connected-account join is REQUIRED here (unlike the + // roster queries) because the WHERE filters its columns. result := ds.Db.Preload(clause.Associations). Preload("AwardRecords.Award"). Joins(xenforo.ConnectedAccountJoin). @@ -140,22 +115,22 @@ func (ds Mysql) FindProfileByDiscordID(discordId string) (*proto.Profile, error) return profiles[profile.RelationId], nil } -func (ds Mysql) generateProtoProfile(profile milpacs.Profile) (*proto.Profile, error) { - milpac := &proto.Profile{ - User: &proto.User{ +func (ds Mysql) generateProtoProfile(profile milpacs.Profile) (*types.Profile, error) { + milpac := &types.Profile{ + User: &types.User{ UserId: profile.XfUser.UserID, Username: profile.XfUser.Username, }, - Rank: &proto.Rank{ + Rank: &types.Rank{ RankId: profile.RankID, - RankShort: strings.TrimPrefix(proto.RankType(profile.RankID).String(), "RANK_TYPE_"), + RankShort: types.RankType(profile.RankID).RankShort(), RankFull: profile.Rank.Title, RankImageUrl: profile.Rank.ImageURL(), }, RealName: profile.UnmarshalCustomFields().RealName, UniformUrl: profile.UniformUrl(), - Roster: proto.RosterType(profile.RosterId), - Primary: &proto.Position{ + Roster: types.RosterType(profile.RosterId), + Primary: &types.Position{ PositionTitle: profile.Primary.PositionTitle, PositionId: profile.Primary.PositionId, }, @@ -166,23 +141,12 @@ func (ds Mysql) generateProtoProfile(profile milpacs.Profile) (*proto.Profile, e PromotionDate: profile.UnmarshalCustomFields().PromoDate, Mos: profile.UnmarshalCustomFields().Mos, ConsoleGamertag: profile.UnmarshalCustomFields().ConsoleGamertag, - KeycloakId: extractKeycloakID(profile), DiscordId: extractDiscordID(profile), } return milpac, nil } -func extractKeycloakID(profile milpacs.Profile) string { - for _, connection := range profile.ConnectedAccount { - if connection.Provider == "keycloak" { - return connection.ProviderKey - } - } - - return "" -} - func extractDiscordID(profile milpacs.Profile) string { for _, connection := range profile.ConnectedAccount { if connection.Provider == "nfDiscord" { @@ -193,8 +157,8 @@ func extractDiscordID(profile milpacs.Profile) string { return "" } -func (ds Mysql) collectSecondaryPositions(positionIds string) []*proto.Position { - var positions []*proto.Position +func (ds Mysql) collectSecondaryPositions(positionIds string) []*types.Position { + var positions []*types.Position if positionIds == "" { return positions @@ -203,7 +167,7 @@ func (ds Mysql) collectSecondaryPositions(positionIds string) []*proto.Position for _, id := range strings.Split(positionIds, ",") { var position milpacs.Position ds.Db.First(&position, id) - positions = append(positions, &proto.Position{ + positions = append(positions, &types.Position{ PositionTitle: position.PositionTitle, PositionId: position.PositionId, }) @@ -211,13 +175,13 @@ func (ds Mysql) collectSecondaryPositions(positionIds string) []*proto.Position return positions } -func collectRecords(recordRows []milpacs.Record) []*proto.Record { - var records []*proto.Record +func collectRecords(recordRows []milpacs.Record) []*types.Record { + var records []*types.Record for _, recordRow := range recordRows { - record := &proto.Record{ + record := &types.Record{ RecordDetails: recordRow.Details, - RecordType: proto.RecordType(recordRow.RecordTypeId), + RecordType: types.RecordType(recordRow.RecordTypeId), RecordDate: stringToTime(strconv.Itoa(int(recordRow.RecordDate))).Format(layoutISO), RecordUid: recordRow.RecordID, } @@ -227,11 +191,11 @@ func collectRecords(recordRows []milpacs.Record) []*proto.Record { return records } -func collectAwards(awardRows []milpacs.AwardRecord) []*proto.Award { - var awards []*proto.Award +func collectAwards(awardRows []milpacs.AwardRecord) []*types.Award { + var awards []*types.Award for _, awardRow := range awardRows { - award := &proto.Award{ + award := &types.Award{ AwardName: awardRow.Award.Title, AwardDetails: awardRow.Details, AwardDate: stringToTime(strconv.Itoa(int(awardRow.AwardDate))).Format(layoutISO), @@ -253,14 +217,13 @@ func stringToTime(s string) time.Time { return time.Unix(sec, 0) } -func (ds Mysql) FindLiteRosterByType(rosterType proto.RosterType) (*proto.LiteRoster, error) { +func (ds Mysql) FindLiteRosterByType(rosterType types.RosterType) (*types.LiteRoster, error) { var rosterProfiles []milpacs.Profile - Info.Println("Searching for lite roster: ", rosterType.String(), "id:", uint(rosterType.Number())) + Info.Println("Searching for lite roster: ", rosterType.String(), "id:", uint(rosterType)) result := ds.Db.Preload(clause.Associations). Omit("Records", "AwardRecords"). - Joins(xenforo.ConnectedAccountJoin). - Where(map[string]interface{}{"roster_id": uint(rosterType.Number())}). + Where(map[string]interface{}{"roster_id": uint(rosterType)}). Find(&rosterProfiles) if result.Error != nil { return nil, fmt.Errorf("find lite roster %s: %w", rosterType, result.Error) @@ -271,25 +234,25 @@ func (ds Mysql) FindLiteRosterByType(rosterType proto.RosterType) (*proto.LiteRo return nil, err } - return &proto.LiteRoster{Profiles: profiles}, nil + return &types.LiteRoster{Profiles: profiles}, nil } -func (ds Mysql) generateLiteProtoProfile(profile milpacs.Profile) (*proto.LiteProfile, error) { - milpac := &proto.LiteProfile{ - User: &proto.User{ +func (ds Mysql) generateLiteProtoProfile(profile milpacs.Profile) (*types.LiteProfile, error) { + milpac := &types.LiteProfile{ + User: &types.User{ UserId: profile.XfUser.UserID, Username: profile.XfUser.Username, }, - Rank: &proto.Rank{ + Rank: &types.Rank{ RankId: profile.RankID, - RankShort: strings.TrimPrefix(proto.RankType(profile.RankID).String(), "RANK_TYPE_"), + RankShort: types.RankType(profile.RankID).RankShort(), RankFull: profile.Rank.Title, RankImageUrl: profile.Rank.ImageURL(), }, RealName: profile.UnmarshalCustomFields().RealName, UniformUrl: profile.UniformUrl(), - Roster: proto.RosterType(profile.RosterId), - Primary: &proto.Position{ + Roster: types.RosterType(profile.RosterId), + Primary: &types.Position{ PositionTitle: profile.Primary.PositionTitle, PositionId: profile.Primary.PositionId, }, @@ -298,7 +261,6 @@ func (ds Mysql) generateLiteProtoProfile(profile milpacs.Profile) (*proto.LitePr PromotionDate: profile.UnmarshalCustomFields().PromoDate, Mos: profile.UnmarshalCustomFields().Mos, ConsoleGamertag: profile.UnmarshalCustomFields().ConsoleGamertag, - KeycloakId: extractKeycloakID(profile), DiscordId: extractDiscordID(profile), AwardDate: getLatestAwardDate(profile), RecordDate: getLatestServiceRecordDate(profile), @@ -308,7 +270,7 @@ func (ds Mysql) generateLiteProtoProfile(profile milpacs.Profile) (*proto.LitePr return milpac, nil } -func (ds Mysql) FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, error) { +func (ds Mysql) FindProfilesByPosition(positionQuery string) (*types.LiteRoster, error) { var profiles []milpacs.Profile Info.Printf("Searching for profiles with position matching: %s", positionQuery) @@ -319,7 +281,6 @@ func (ds Mysql) FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, result := ds.Db.Preload(clause.Associations). Omit("Records", "AwardRecords"). - Joins(xenforo.ConnectedAccountJoin). Joins("LEFT JOIN xf_nf_rosters_position pos ON pos.position_id = xf_nf_rosters_user.position_id OR FIND_IN_SET(pos.position_id, xf_nf_rosters_user.secondary_position_ids)"). Where("pos.position_title LIKE ? AND (pos.position_id = xf_nf_rosters_user.position_id OR pos.possible_secondary = ?)", likeQuery, true). @@ -334,24 +295,23 @@ func (ds Mysql) FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, return nil, err } - return &proto.LiteRoster{Profiles: profileMap}, nil + return &types.LiteRoster{Profiles: profileMap}, nil } -func (ds Mysql) FindS1UniformsRosterByType(rosterType proto.RosterType) (*proto.S1UniformsRoster, error) { +func (ds Mysql) FindS1UniformsRosterByType(rosterType types.RosterType) (*types.S1UniformsRoster, error) { var rosterProfiles []milpacs.Profile - Info.Println("Searching for S1 Uniforms roster: ", rosterType.String(), "id:", uint(rosterType.Number())) + Info.Println("Searching for S1 Uniforms roster: ", rosterType.String(), "id:", uint(rosterType)) result := ds.Db.Preload(clause.Associations). Preload("Primary.Group"). Preload("AwardRecords.Award"). - Joins(xenforo.ConnectedAccountJoin). - Where(map[string]interface{}{"roster_id": uint(rosterType.Number())}). + Where(map[string]interface{}{"roster_id": uint(rosterType)}). Find(&rosterProfiles) if result.Error != nil { return nil, fmt.Errorf("find s1 uniforms roster %s: %w", rosterType, result.Error) } - var profiles = make(map[uint64]*proto.S1UniformsProfile, len(rosterProfiles)) + var profiles = make(map[uint64]*types.S1UniformsProfile, len(rosterProfiles)) for _, profile := range rosterProfiles { milpac, err := ds.generateS1UniformsProtoProfile(profile) @@ -361,19 +321,19 @@ func (ds Mysql) FindS1UniformsRosterByType(rosterType proto.RosterType) (*proto. profiles[profile.RelationId] = milpac } - protoRoster := &proto.S1UniformsRoster{Profiles: profiles} + roster := &types.S1UniformsRoster{Profiles: profiles} - return protoRoster, nil + return roster, nil } -func (ds Mysql) generateS1UniformsProtoProfile(profile milpacs.Profile) (*proto.S1UniformsProfile, error) { - milpac := &proto.S1UniformsProfile{ - User: &proto.User{ +func (ds Mysql) generateS1UniformsProtoProfile(profile milpacs.Profile) (*types.S1UniformsProfile, error) { + milpac := &types.S1UniformsProfile{ + User: &types.User{ UserId: profile.XfUser.UserID, Username: profile.XfUser.Username, }, - Rank: &proto.S1UniformsRank{ - RankShort: strings.TrimPrefix(proto.RankType(profile.RankID).String(), "RANK_TYPE_"), + Rank: &types.S1UniformsRank{ + RankShort: types.RankType(profile.RankID).RankShort(), RankFull: profile.Rank.Title, RankImageUrl: profile.Rank.ImageURL(), }, @@ -381,7 +341,7 @@ func (ds Mysql) generateS1UniformsProtoProfile(profile milpacs.Profile) (*proto. UniformUrl: profile.UniformUrl(), UniformDate: getUniformDate(profile), UniformUpdateTriggerDate: getUniformUpdateTriggerDate(profile), - Roster: proto.RosterType(profile.RosterId), + Roster: types.RosterType(profile.RosterId), PrimaryPositionTitle: profile.Primary.PositionTitle, Secondaries: ds.collectS1UniformsSecondaryPositions(profile.SecondaryPositionIds), JoinDate: profile.UnmarshalCustomFields().JoinDate, @@ -392,8 +352,8 @@ func (ds Mysql) generateS1UniformsProtoProfile(profile milpacs.Profile) (*proto. return milpac, nil } -func (ds Mysql) collectS1UniformsSecondaryPositions(positionIds string) []*proto.S1UniformsPosition { - var positions []*proto.S1UniformsPosition +func (ds Mysql) collectS1UniformsSecondaryPositions(positionIds string) []*types.S1UniformsPosition { + var positions []*types.S1UniformsPosition if positionIds == "" { return positions @@ -402,7 +362,7 @@ func (ds Mysql) collectS1UniformsSecondaryPositions(positionIds string) []*proto for _, id := range strings.Split(positionIds, ",") { var position milpacs.Position ds.Db.First(&position, id) - positions = append(positions, &proto.S1UniformsPosition{ + positions = append(positions, &types.S1UniformsPosition{ PositionTitle: position.PositionTitle, }) } @@ -417,12 +377,12 @@ func getUniformDate(profile milpacs.Profile) string { } func getUniformUpdateTriggerDate(profile milpacs.Profile) string { - relevantRecordTypes := map[proto.RecordType]bool{ - proto.RecordType_RECORD_TYPE_PROMOTION: true, - proto.RecordType_RECORD_TYPE_ASSIGNMENT: true, - proto.RecordType_RECORD_TYPE_ELOA: true, - proto.RecordType_RECORD_TYPE_NAME_CHANGE: true, - proto.RecordType_RECORD_TYPE_GRADUATION: true, + relevantRecordTypes := map[types.RecordType]bool{ + types.RecordTypePromotion: true, + types.RecordTypeAssignment: true, + types.RecordTypeEloa: true, + types.RecordTypeNameChange: true, + types.RecordTypeGraduation: true, } var latestTimestamp int64 @@ -434,7 +394,7 @@ func getUniformUpdateTriggerDate(profile milpacs.Profile) string { } for _, record := range profile.Records { - if relevantRecordTypes[proto.RecordType(record.RecordTypeId)] && int64(record.RecordDate) > latestTimestamp { + if relevantRecordTypes[types.RecordType(record.RecordTypeId)] && int64(record.RecordDate) > latestTimestamp { latestTimestamp = int64(record.RecordDate) } } @@ -466,7 +426,7 @@ func getPositionGroup(profile milpacs.Profile) string { return primaryGroup } -func (ds Mysql) FindAllRanks() ([]*proto.RankExpanded, error) { +func (ds Mysql) FindAllRanks() ([]*types.RankExpanded, error) { Info.Println("Searching for all ranks") var ranks []milpacs.Rank @@ -475,21 +435,21 @@ func (ds Mysql) FindAllRanks() ([]*proto.RankExpanded, error) { return nil, fmt.Errorf("error fetching ranks: %w", result.Error) } - protoRanks := make([]*proto.RankExpanded, len(ranks)) + expandedRanks := make([]*types.RankExpanded, len(ranks)) for i, rank := range ranks { - protoRanks[i] = &proto.RankExpanded{ + expandedRanks[i] = &types.RankExpanded{ RankId: rank.RankId, - RankShort: strings.TrimPrefix(proto.RankType(rank.RankId).String(), "RANK_TYPE_"), + RankShort: types.RankType(rank.RankId).RankShort(), RankFull: rank.Title, RankImageUrl: rank.ImageURL(), RankDisplayOrder: uint32(rank.DisplayOrder), } } - return protoRanks, nil + return expandedRanks, nil } -func (ds Mysql) FindAllPositionGroups() ([]*proto.PositionGroup, error) { +func (ds Mysql) FindAllPositionGroups() ([]*types.PositionGroup, error) { Info.Println("Searching for all position groups") var groups []milpacs.PositionGroups @@ -498,7 +458,7 @@ func (ds Mysql) FindAllPositionGroups() ([]*proto.PositionGroup, error) { return nil, fmt.Errorf("error fetching position groups: %w", result.Error) } - protoGroups := make([]*proto.PositionGroup, len(groups)) + positionGroups := make([]*types.PositionGroup, len(groups)) for i, group := range groups { var positions []milpacs.Position @@ -512,9 +472,9 @@ func (ds Mysql) FindAllPositionGroups() ([]*proto.PositionGroup, error) { group.PositionGroupId, posResult.Error) } - protoPositions := make([]*proto.PositionExpanded, len(positions)) + expandedPositions := make([]*types.PositionExpanded, len(positions)) for j, pos := range positions { - protoPositions[j] = &proto.PositionExpanded{ + expandedPositions[j] = &types.PositionExpanded{ PositionId: pos.PositionId, PositionTitle: pos.PositionTitle, PositionDisplayOrder: uint32(pos.DisplayOrder), @@ -522,15 +482,15 @@ func (ds Mysql) FindAllPositionGroups() ([]*proto.PositionGroup, error) { } } - protoGroups[i] = &proto.PositionGroup{ + positionGroups[i] = &types.PositionGroup{ GroupId: group.PositionGroupId, GroupTitle: group.Title, GroupDisplayOrder: uint32(group.DisplayOrder), - Positions: protoPositions, + Positions: expandedPositions, } } - return protoGroups, nil + return positionGroups, nil } func getLatestServiceRecordDate(profile milpacs.Profile) string { @@ -620,41 +580,41 @@ func getUserIDs(profiles []milpacs.Profile) []uint64 { } // ohgodwhy -func (ds Mysql) processLiteProfiles(profiles []milpacs.Profile) (map[uint64]*proto.LiteProfile, error) { +func (ds Mysql) processLiteProfiles(profiles []milpacs.Profile) (map[uint64]*types.LiteProfile, error) { forumPostDates := ds.getLatestForumPostDates(profiles) - var profileMap = make(map[uint64]*proto.LiteProfile, len(profiles)) + var profileMap = make(map[uint64]*types.LiteProfile, len(profiles)) for _, profile := range profiles { - protoProfile, err := ds.generateLiteProtoProfile(profile) + liteProfile, err := ds.generateLiteProtoProfile(profile) if err != nil { return nil, fmt.Errorf("error generating lite profile: %w", err) } - protoProfile.LastForumPostDate = forumPostDates[profile.UserID] - profileMap[profile.RelationId] = protoProfile + liteProfile.LastForumPostDate = forumPostDates[profile.UserID] + profileMap[profile.RelationId] = liteProfile } return profileMap, nil } -func (ds Mysql) processProfiles(profiles []milpacs.Profile) (map[uint64]*proto.Profile, error) { +func (ds Mysql) processProfiles(profiles []milpacs.Profile) (map[uint64]*types.Profile, error) { forumPostDates := ds.getLatestForumPostDates(profiles) - var profileMap = make(map[uint64]*proto.Profile, len(profiles)) + var profileMap = make(map[uint64]*types.Profile, len(profiles)) for _, profile := range profiles { - protoProfile, err := ds.generateProtoProfile(profile) + fullProfile, err := ds.generateProtoProfile(profile) if err != nil { return nil, fmt.Errorf("error generating profile: %w", err) } - protoProfile.LastForumPostDate = forumPostDates[profile.UserID] - profileMap[profile.RelationId] = protoProfile + fullProfile.LastForumPostDate = forumPostDates[profile.UserID] + profileMap[profile.RelationId] = fullProfile } return profileMap, nil } -func (ds Mysql) FindAwol() ([]*proto.Awol, error) { +func (ds Mysql) FindAwol() ([]*types.Awol, error) { Info.Println("Searching for AWOL troopers") var awols []struct { GroupName string `gorm:"column:group_name"` @@ -694,9 +654,9 @@ func (ds Mysql) FindAwol() ([]*proto.Awol, error) { return nil, fmt.Errorf("error finding AWOL users: %w", result.Error) } - protoAwols := make([]*proto.Awol, len(awols)) + awolList := make([]*types.Awol, len(awols)) for i, awol := range awols { - protoAwols[i] = &proto.Awol{ + awolList[i] = &types.Awol{ GroupName: awol.GroupName, RankName: awol.RankName, Username: awol.Username, @@ -708,7 +668,7 @@ func (ds Mysql) FindAwol() ([]*proto.Awol, error) { } } - return protoAwols, nil + return awolList, nil } func (ds Mysql) ValidateApiKey(rawKey string) (*ApiKeyResult, error) { @@ -744,14 +704,13 @@ func (ds Mysql) ValidateApiKey(rawKey string) (*ApiKeyResult, error) { }, nil } -func (ds Mysql) FindProfileByGamertag(gamertag string) (*proto.Profile, error) { +func (ds Mysql) FindProfileByGamertag(gamertag string) (*types.Profile, error) { var profile milpacs.Profile Info.Println("Searching for user with gamertag: ", gamertag) result := ds.Db.Preload(clause.Associations). Preload("AwardRecords.Award"). - Joins(xenforo.ConnectedAccountJoin). Joins(milpacs.FieldValueJoin). Where("xf_nf_rosters_field_value.field_id = ? AND xf_nf_rosters_field_value.field_value LIKE ?", "consoleGamertag", gamertag). First(&profile) diff --git a/datastores/tickets.go b/datastores/tickets.go index 913f962..5326920 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -8,8 +8,8 @@ import ( "strconv" "strings" - "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" + "github.com/7cav/api/types" "github.com/7cav/api/xenforo" "github.com/spf13/viper" ) @@ -127,7 +127,7 @@ func (ds *Mysql) loadPhraseMap(ctx context.Context, fam phraseFamily) (map[uint3 return out, nil } -func (ds *Mysql) ListTickets(ctx context.Context, rc TicketReferenceCache, f *ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { +func (ds *Mysql) ListTickets(ctx context.Context, rc TicketReferenceCache, f *ListTicketsFilter) ([]*types.Ticket, string, bool, error) { perPage := f.PerPage if perPage == 0 || perPage > 100 { if perPage == 0 { @@ -196,7 +196,7 @@ func (ds *Mysql) ListTickets(ctx context.Context, rc TicketReferenceCache, f *Li rows = rows[:perPage] } - out := make([]*proto.Ticket, 0, len(rows)) + out := make([]*types.Ticket, 0, len(rows)) for i := range rows { out = append(out, generateTicketProto(&rows[i], rc, ds.forumBaseURL())) } @@ -209,15 +209,25 @@ func (ds *Mysql) ListTickets(ctx context.Context, rc TicketReferenceCache, f *Li return out, nextCursor, hasMore, nil } -// generateTicketProto maps a xenforo.Ticket to proto.Ticket, resolving +// generateTicketProto maps a xenforo.Ticket to types.Ticket, resolving // reference-cached names and assembling the custom_fields map. -func generateTicketProto(t *xenforo.Ticket, rc TicketReferenceCache, forumBase string) *proto.Ticket { - out := &proto.Ticket{ +// +// Allocation discipline (frozen wire contract, golden-pinned by +// contract/goldens/tickets): the three collection fields always serialize as +// []/{} when empty, never null. participants and categoryAncestorIds are the +// plain-slice fields on types.Ticket (no nil-safe List wrapper), so they are +// allocated to a non-nil empty here; customFields is an allocated map. +func generateTicketProto(t *xenforo.Ticket, rc TicketReferenceCache, forumBase string) *types.Ticket { + ancestors := rc.CategoryAncestors(t.TicketCategoryID) + if ancestors == nil { + ancestors = []uint32{} + } + out := &types.Ticket{ TicketId: t.TicketID, TicketRef: t.TicketRef, Title: t.Title, CategoryId: t.TicketCategoryID, - CategoryAncestorIds: rc.CategoryAncestors(t.TicketCategoryID), + CategoryAncestorIds: ancestors, TicketState: t.TicketState, StatusId: t.StatusID, StatusName: rc.StatusName(t.StatusID), @@ -231,6 +241,7 @@ func generateTicketProto(t *xenforo.Ticket, rc TicketReferenceCache, forumBase s StarterUsername: t.StarterUsername, AssignedUserId: t.AssignedUserID, AssignedUsername: t.AssignedUsername, + Participants: make([]*types.TicketParticipant, 0, len(t.Participants)), StartDate: t.StartDate, LastMessageDate: t.LastMessageDate, LastMessageUserId: t.LastMessageUserID, @@ -247,7 +258,7 @@ func generateTicketProto(t *xenforo.Ticket, rc TicketReferenceCache, forumBase s out.CustomFields[fv.FieldID] = fv.FieldValue } for _, p := range t.Participants { - out.Participants = append(out.Participants, &proto.TicketParticipant{ + out.Participants = append(out.Participants, &types.TicketParticipant{ UserId: p.UserID, LastReadDate: p.LastReadDate, }) } @@ -323,7 +334,7 @@ func decodeMessageCursor(c string) (uint32, error) { return uint32(pos), nil } -func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBase string) (*proto.Ticket, error) { +func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBase string) (*types.Ticket, error) { var row xenforo.Ticket tx := ds.Db.WithContext(ctx). Preload("Participants"). @@ -340,7 +351,7 @@ func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketI return generateTicketProto(&row, rc, strings.TrimRight(base, "/")), nil } -func (ds *Mysql) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) ([]*proto.Message, uint32, error) { +func (ds *Mysql) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) ([]*types.Message, uint32, error) { var total int64 q := ds.Db.WithContext(ctx).Model(&xenforo.TicketMessage{}).Where("ticket_id = ?", ticketID) if !includeHidden { @@ -359,14 +370,14 @@ func (ds *Mysql) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n if tx.Error != nil { return nil, 0, tx.Error } - out := make([]*proto.Message, 0, len(rows)) + out := make([]*types.Message, 0, len(rows)) for i := range rows { out = append(out, messageToProto(&rows[i])) } return out, uint32(total), nil } -func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*proto.Message, string, bool, error) { +func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*types.Message, string, bool, error) { if perPage == 0 || perPage > 100 { if perPage == 0 { perPage = 50 @@ -391,7 +402,7 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterC if hasMore { rows = rows[:perPage] } - out := make([]*proto.Message, 0, len(rows)) + out := make([]*types.Message, 0, len(rows)) for i := range rows { out = append(out, messageToProto(&rows[i])) } @@ -402,11 +413,11 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterC return out, next, hasMore, nil } -func (ds *Mysql) ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error) { +func (ds *Mysql) ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*types.Category, error) { tree := rc.CategoryTree() - out := make([]*proto.Category, 0, len(tree)) + out := make([]*types.Category, 0, len(tree)) for _, c := range tree { - out = append(out, &proto.Category{ + out = append(out, &types.Category{ CategoryId: c.ID, Title: c.Title, Description: c.Description, @@ -419,8 +430,8 @@ func (ds *Mysql) ListCategories(ctx context.Context, rc TicketReferenceCache) ([ return out, nil } -func messageToProto(m *xenforo.TicketMessage) *proto.Message { - return &proto.Message{ +func messageToProto(m *xenforo.TicketMessage) *types.Message { + return &types.Message{ MessageId: m.MessageID, TicketId: m.TicketID, UserId: m.UserID, @@ -435,7 +446,7 @@ func messageToProto(m *xenforo.TicketMessage) *proto.Message { } } -func (ds *Mysql) GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBase string) (*proto.Ticket, error) { +func (ds *Mysql) GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBase string) (*types.Ticket, error) { var row xenforo.Ticket tx := ds.Db.WithContext(ctx). Preload("Participants"). diff --git a/rest/awol.go b/rest/awol.go index af246c0..808a519 100644 --- a/rest/awol.go +++ b/rest/awol.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/types" ) @@ -19,27 +18,6 @@ func getAwol(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "error fetching AWOL list: %v", err) return } - writeJSON(w, r, types.AwolResponse{Awols: awolsFromProto(awols)}) + writeJSON(w, r, types.AwolResponse{Awols: awols}) }) } - -// awolsFromProto maps the datastore's proto-typed rows to the wire types. -// The mapping layer disappears at cutover (#134); until then each handler -// owns its map — and the allocation discipline: empty collections are -// allocated ([] on the wire), never nil. -func awolsFromProto(in []*proto.Awol) []*types.Awol { - out := make([]*types.Awol, 0, len(in)) - for _, a := range in { - out = append(out, &types.Awol{ - GroupName: a.GetGroupName(), - RankName: a.GetRankName(), - Username: a.GetUsername(), - UserId: a.GetUserId(), - HumanDate: a.GetHumanDate(), - Timestamp: a.GetTimestamp(), - PostId: a.GetPostId(), - MilpacId: a.GetMilpacId(), - }) - } - return out -} diff --git a/rest/milpacs.go b/rest/milpacs.go index b38b0c7..ffee1c2 100644 --- a/rest/milpacs.go +++ b/rest/milpacs.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/types" "gorm.io/gorm" ) @@ -25,7 +24,7 @@ func getAllRanks(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "error fetching ranks: %v", err) return } - writeJSON(w, r, types.RanksResponse{Ranks: ranksFromProto(ranks)}) + writeJSON(w, r, types.RanksResponse{Ranks: ranks}) }) } @@ -283,95 +282,13 @@ func queryField(r *http.Request, protoName, jsonName string) (vals []string, err return vals, nil } -// writeProfile maps one datastore profile to the wire type and writes it. A -// nil profile with no error is a datastore-invariant violation (unreachable -// through the real datastore): a clean 500, not a fabricated sparse 200 — -// the proto getters would happily marshal a zero-value profile. -func writeProfile(w http.ResponseWriter, r *http.Request, p *proto.Profile) { +// writeProfile writes one datastore profile. A nil profile with no error is a +// datastore-invariant violation (unreachable through the real datastore): a +// clean 500, not a fabricated sparse 200. +func writeProfile(w http.ResponseWriter, r *http.Request, p *types.Profile) { if p == nil { writeError(w, r, codeInternal, "datastore returned no profile") return } - writeJSON(w, r, profileFromProto(p)) -} - -// profileFromProto maps the datastore's proto-typed profile to the wire type. -// Allocation discipline: collections are always allocated ([] on the wire, -// never null); unset nested messages stay nil (null on the wire). keycloakId -// is dropped here — the wire type never had the field (documented break). -func profileFromProto(p *proto.Profile) *types.Profile { - out := &types.Profile{ - RealName: p.GetRealName(), - UniformUrl: p.GetUniformUrl(), - Roster: types.RosterType(p.GetRoster()), - Secondaries: make([]*types.Position, 0, len(p.GetSecondaries())), - Records: make([]*types.Record, 0, len(p.GetRecords())), - Awards: make([]*types.Award, 0, len(p.GetAwards())), - JoinDate: p.GetJoinDate(), - PromotionDate: p.GetPromotionDate(), - DiscordId: p.GetDiscordId(), - LastForumPostDate: p.GetLastForumPostDate(), - Mos: p.GetMos(), - ConsoleGamertag: p.GetConsoleGamertag(), - } - if u := p.GetUser(); u != nil { - out.User = &types.User{UserId: u.GetUserId(), Username: u.GetUsername()} - } - if rk := p.GetRank(); rk != nil { - out.Rank = &types.Rank{ - RankShort: rk.GetRankShort(), - RankFull: rk.GetRankFull(), - RankImageUrl: rk.GetRankImageUrl(), - RankId: rk.GetRankId(), - } - } - out.Primary = positionFromProto(p.GetPrimary()) - for _, s := range p.GetSecondaries() { - out.Secondaries = append(out.Secondaries, positionFromProto(s)) - } - for _, rec := range p.GetRecords() { - out.Records = append(out.Records, &types.Record{ - RecordDetails: rec.GetRecordDetails(), - RecordType: types.RecordType(rec.GetRecordType()), - RecordDate: rec.GetRecordDate(), - RecordUid: rec.GetRecordUid(), - }) - } - for _, a := range p.GetAwards() { - out.Awards = append(out.Awards, &types.Award{ - AwardDetails: a.GetAwardDetails(), - AwardName: a.GetAwardName(), - AwardDate: a.GetAwardDate(), - AwardImageUrl: a.GetAwardImageUrl(), - AwardUid: a.GetAwardUid(), - }) - } - return out -} - -// positionFromProto maps a position, preserving nil (null on the wire). -func positionFromProto(p *proto.Position) *types.Position { - if p == nil { - return nil - } - return &types.Position{PositionTitle: p.GetPositionTitle(), PositionId: p.GetPositionId()} -} - -// ranksFromProto maps the datastore's proto-typed rows to the wire types. -// The mapping layer disappears at cutover (#134) when the Datastore -// interface itself moves to the types package; until then each handler owns -// its map — and the allocation discipline: empty collections are allocated -// ([] on the wire), never nil. -func ranksFromProto(in []*proto.RankExpanded) []*types.RankExpanded { - out := make([]*types.RankExpanded, 0, len(in)) - for _, r := range in { - out = append(out, &types.RankExpanded{ - RankShort: r.GetRankShort(), - RankFull: r.GetRankFull(), - RankImageUrl: r.GetRankImageUrl(), - RankId: r.GetRankId(), - RankDisplayOrder: r.GetRankDisplayOrder(), - }) - } - return out + writeJSON(w, r, p) } diff --git a/rest/positions.go b/rest/positions.go index 31f1244..0fd36eb 100644 --- a/rest/positions.go +++ b/rest/positions.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/types" ) @@ -20,7 +19,7 @@ func getPositionGroups(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "error fetching position groups: %v", err) return } - writeJSON(w, r, types.PositionGroupsResponse{Groups: positionGroupsFromProto(groups)}) + writeJSON(w, r, types.PositionGroupsResponse{Groups: groups}) }) } @@ -70,74 +69,6 @@ func searchByPosition(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } - writeJSON(w, r, liteRosterFromProto(roster)) + writeJSON(w, r, roster) }) } - -// liteRosterFromProto maps the datastore's proto-typed lite roster to the -// wire type. Allocation discipline: the profiles map is always allocated -// ({} on the wire, never null); unset nested messages stay nil (null on the -// wire). keycloakId is dropped — the wire type never had the field -// (documented break). -func liteRosterFromProto(in *proto.LiteRoster) *types.LiteRoster { - out := &types.LiteRoster{Profiles: make(map[uint64]*types.LiteProfile, len(in.GetProfiles()))} - for id, p := range in.GetProfiles() { - lp := &types.LiteProfile{ - RealName: p.GetRealName(), - UniformUrl: p.GetUniformUrl(), - Roster: types.RosterType(p.GetRoster()), - Secondaries: make([]*types.Position, 0, len(p.GetSecondaries())), - JoinDate: p.GetJoinDate(), - PromotionDate: p.GetPromotionDate(), - DiscordId: p.GetDiscordId(), - AwardDate: p.GetAwardDate(), - RecordDate: p.GetRecordDate(), - LastForumPostDate: p.GetLastForumPostDate(), - Mos: p.GetMos(), - ConsoleGamertag: p.GetConsoleGamertag(), - } - if u := p.GetUser(); u != nil { - lp.User = &types.User{UserId: u.GetUserId(), Username: u.GetUsername()} - } - if rk := p.GetRank(); rk != nil { - lp.Rank = &types.Rank{ - RankShort: rk.GetRankShort(), - RankFull: rk.GetRankFull(), - RankImageUrl: rk.GetRankImageUrl(), - RankId: rk.GetRankId(), - } - } - lp.Primary = positionFromProto(p.GetPrimary()) - for _, s := range p.GetSecondaries() { - lp.Secondaries = append(lp.Secondaries, positionFromProto(s)) - } - out.Profiles[id] = lp - } - return out -} - -// positionGroupsFromProto maps the datastore's proto-typed rows to the wire -// types. The mapping layer disappears at cutover (#134); until then each -// handler owns its map — and the allocation discipline: empty collections are -// allocated ([] on the wire), never nil. -func positionGroupsFromProto(in []*proto.PositionGroup) []*types.PositionGroup { - out := make([]*types.PositionGroup, 0, len(in)) - for _, g := range in { - group := &types.PositionGroup{ - GroupId: g.GetGroupId(), - GroupTitle: g.GetGroupTitle(), - GroupDisplayOrder: g.GetGroupDisplayOrder(), - Positions: make([]*types.PositionExpanded, 0, len(g.GetPositions())), - } - for _, p := range g.GetPositions() { - group.Positions = append(group.Positions, &types.PositionExpanded{ - PositionTitle: p.GetPositionTitle(), - PositionId: p.GetPositionId(), - PositionDisplayOrder: p.GetPositionDisplayOrder(), - PositionPossibleSecondary: p.GetPositionPossibleSecondary(), - }) - } - out = append(out, group) - } - return out -} diff --git a/rest/rosters.go b/rest/rosters.go index c7fb923..d3df427 100644 --- a/rest/rosters.go +++ b/rest/rosters.go @@ -4,7 +4,6 @@ import ( "net/http" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/types" ) @@ -60,7 +59,7 @@ func getRoster(ds datastores.Datastore) http.Handler { if !ok { return } - roster, err := ds.FindRosterByType(proto.RosterType(rt)) + roster, err := ds.FindRosterByType(rt) if err != nil { writeError(w, r, codeInternal, "fetch roster %s: %v", rt, err) return @@ -72,11 +71,7 @@ func getRoster(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } - out := types.Roster{Profiles: make(map[uint64]*types.Profile, len(roster.GetProfiles()))} - for id, p := range roster.GetProfiles() { - out.Profiles[id] = profileFromProto(p) - } - writeJSON(w, r, out) + writeJSON(w, r, roster) }) } @@ -89,7 +84,7 @@ func getLiteRoster(ds datastores.Datastore) http.Handler { if !ok { return } - roster, err := ds.FindLiteRosterByType(proto.RosterType(rt)) + roster, err := ds.FindLiteRosterByType(rt) if err != nil { writeError(w, r, codeInternal, "fetch lite roster %s: %v", rt, err) return @@ -98,51 +93,10 @@ func getLiteRoster(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } - out := types.LiteRoster{Profiles: make(map[uint64]*types.LiteProfile, len(roster.GetProfiles()))} - for id, p := range roster.GetProfiles() { - out.Profiles[id] = liteProfileFromProto(p) - } - writeJSON(w, r, out) + writeJSON(w, r, roster) }) } -// liteProfileFromProto maps the datastore's proto-typed lite profile to the -// wire type. Same discipline as profileFromProto: collections always -// allocated, unset nested messages stay nil, keycloakId dropped (the wire -// type never had the field — documented break). -func liteProfileFromProto(p *proto.LiteProfile) *types.LiteProfile { - out := &types.LiteProfile{ - RealName: p.GetRealName(), - UniformUrl: p.GetUniformUrl(), - Roster: types.RosterType(p.GetRoster()), - Secondaries: make([]*types.Position, 0, len(p.GetSecondaries())), - JoinDate: p.GetJoinDate(), - PromotionDate: p.GetPromotionDate(), - DiscordId: p.GetDiscordId(), - AwardDate: p.GetAwardDate(), - RecordDate: p.GetRecordDate(), - LastForumPostDate: p.GetLastForumPostDate(), - Mos: p.GetMos(), - ConsoleGamertag: p.GetConsoleGamertag(), - } - if u := p.GetUser(); u != nil { - out.User = &types.User{UserId: u.GetUserId(), Username: u.GetUsername()} - } - if rk := p.GetRank(); rk != nil { - out.Rank = &types.Rank{ - RankShort: rk.GetRankShort(), - RankFull: rk.GetRankFull(), - RankImageUrl: rk.GetRankImageUrl(), - RankId: rk.GetRankId(), - } - } - out.Primary = positionFromProto(p.GetPrimary()) - for _, s := range p.GetSecondaries() { - out.Secondaries = append(out.Secondaries, positionFromProto(s)) - } - return out -} - // getS1UniformsRoster serves GET /api/v1/s1/uniforms/{roster}: the same // member set as S1UniformsProfile views. Error message strings frozen from // the old handler (servers/grpc GetS1UniformsRoster). @@ -152,7 +106,7 @@ func getS1UniformsRoster(ds datastores.Datastore) http.Handler { if !ok { return } - roster, err := ds.FindS1UniformsRosterByType(proto.RosterType(rt)) + roster, err := ds.FindS1UniformsRosterByType(rt) if err != nil { writeError(w, r, codeInternal, "fetch s1 uniforms roster %s: %v", rt, err) return @@ -161,50 +115,6 @@ func getS1UniformsRoster(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } - out := types.S1UniformsRoster{Profiles: make(map[uint64]*types.S1UniformsProfile, len(roster.GetProfiles()))} - for id, p := range roster.GetProfiles() { - out.Profiles[id] = s1UniformsProfileFromProto(p) - } - writeJSON(w, r, out) + writeJSON(w, r, roster) }) } - -// s1UniformsProfileFromProto maps the datastore's proto-typed S1 uniforms -// profile to the wire type — same discipline as the other two mappers. -func s1UniformsProfileFromProto(p *proto.S1UniformsProfile) *types.S1UniformsProfile { - out := &types.S1UniformsProfile{ - RealName: p.GetRealName(), - UniformUrl: p.GetUniformUrl(), - UniformDate: p.GetUniformDate(), - UniformUpdateTriggerDate: p.GetUniformUpdateTriggerDate(), - Roster: types.RosterType(p.GetRoster()), - PrimaryPositionTitle: p.GetPrimaryPositionTitle(), - Secondaries: make([]*types.S1UniformsPosition, 0, len(p.GetSecondaries())), - JoinDate: p.GetJoinDate(), - PromotionDate: p.GetPromotionDate(), - AreaOfResponsibility: p.GetAreaOfResponsibility(), - } - if u := p.GetUser(); u != nil { - out.User = &types.User{UserId: u.GetUserId(), Username: u.GetUsername()} - } - if rk := p.GetRank(); rk != nil { - out.Rank = &types.S1UniformsRank{ - RankShort: rk.GetRankShort(), - RankFull: rk.GetRankFull(), - RankImageUrl: rk.GetRankImageUrl(), - } - } - for _, s := range p.GetSecondaries() { - out.Secondaries = append(out.Secondaries, s1PositionFromProto(s)) - } - return out -} - -// s1PositionFromProto maps an S1 uniforms position, preserving nil (null on -// the wire) — the S1 counterpart of positionFromProto. -func s1PositionFromProto(p *proto.S1UniformsPosition) *types.S1UniformsPosition { - if p == nil { - return nil - } - return &types.S1UniformsPosition{PositionTitle: p.GetPositionTitle()} -} diff --git a/rest/tickets.go b/rest/tickets.go index 95ff9ff..87f66c7 100644 --- a/rest/tickets.go +++ b/rest/tickets.go @@ -7,7 +7,6 @@ import ( "strconv" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/types" "gorm.io/gorm" ) @@ -56,9 +55,9 @@ func listTickets(ds datastores.Datastore, rc datastores.TicketReferenceCache) ht writeError(w, r, codeInternal, "list tickets: %v", err) return } - out := make([]*types.Ticket, 0, len(tickets)) - for _, t := range tickets { - out = append(out, ticketFromProto(t)) + out := tickets + if out == nil { + out = []*types.Ticket{} } writeJSON(w, r, types.ListTicketsResponse{ Tickets: out, @@ -118,21 +117,24 @@ func getTicketByRef(ds datastores.Datastore, rc datastores.TicketReferenceCache) // ticket.totalMessageCount but is computed independently (visible-message // COUNT vs replyCount+1 — they diverge on hidden messages; see // types.GetTicketResponse). -func writeTicketResponse(w http.ResponseWriter, r *http.Request, ds datastores.Datastore, ticket *proto.Ticket) { +func writeTicketResponse(w http.ResponseWriter, r *http.Request, ds datastores.Datastore, ticket *types.Ticket) { if ticket == nil { - // A (nil, nil) datastore return is a bug, but the nil-safe proto - // getters would dress it up as a zeroed-garbage 200 — fail loudly. + // A (nil, nil) datastore return is a bug — fail loudly rather than + // serve a zeroed-garbage 200. writeError(w, r, codeInternal, "fetch ticket: nil ticket") return } - msgs, total, err := ds.GetTicketFirstMessages(r.Context(), ticket.GetTicketId(), firstMessagesCount, false) + msgs, total, err := ds.GetTicketFirstMessages(r.Context(), ticket.TicketId, firstMessagesCount, false) if err != nil { writeError(w, r, codeInternal, "fetch ticket messages: %v", err) return } + if msgs == nil { + msgs = []*types.Message{} + } writeJSON(w, r, types.GetTicketResponse{ - Ticket: ticketFromProto(ticket), - FirstMessages: messagesFromProto(msgs), + Ticket: ticket, + FirstMessages: msgs, TotalMessageCount: total, }) } @@ -222,8 +224,11 @@ func listTicketMessages(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "list ticket messages: %v", err) return } + if msgs == nil { + msgs = []*types.Message{} + } writeJSON(w, r, types.ListTicketMessagesResponse{ - Messages: messagesFromProto(msgs), + Messages: msgs, NextCursor: next, HasMore: more, }) @@ -240,100 +245,9 @@ func listCategories(ds datastores.Datastore, rc datastores.TicketReferenceCache) writeError(w, r, codeInternal, "list ticket categories: %v", err) return } - writeJSON(w, r, types.ListCategoriesResponse{Categories: categoriesFromProto(cats)}) + if cats == nil { + cats = []*types.Category{} + } + writeJSON(w, r, types.ListCategoriesResponse{Categories: cats}) }) } - -// ticketFromProto maps one datastore ticket to the wire type (see -// ranksFromProto for the mapping-layer rationale). Allocation discipline: -// participants/ancestors/customFields are always allocated — []/{} on the -// wire, never null. -func ticketFromProto(t *proto.Ticket) *types.Ticket { - participants := make([]*types.TicketParticipant, 0, len(t.GetParticipants())) - for _, p := range t.GetParticipants() { - participants = append(participants, &types.TicketParticipant{ - UserId: p.GetUserId(), - LastReadDate: p.GetLastReadDate(), - }) - } - ancestors := t.GetCategoryAncestorIds() - if ancestors == nil { - ancestors = []uint32{} - } - customFields := t.GetCustomFields() - if customFields == nil { - customFields = map[string]string{} - } - return &types.Ticket{ - TicketId: t.GetTicketId(), - TicketRef: t.GetTicketRef(), - Title: t.GetTitle(), - CategoryId: t.GetCategoryId(), - CategoryTitle: t.GetCategoryTitle(), - CategoryAncestorIds: ancestors, - TicketState: t.GetTicketState(), - StatusId: t.GetStatusId(), - StatusName: t.GetStatusName(), - PriorityId: t.GetPriorityId(), - PriorityName: t.GetPriorityName(), - PrefixId: t.GetPrefixId(), - PrefixName: t.GetPrefixName(), - DiscussionState: t.GetDiscussionState(), - TicketLocked: t.GetTicketLocked(), - StarterUserId: t.GetStarterUserId(), - StarterUsername: t.GetStarterUsername(), - AssignedUserId: t.GetAssignedUserId(), - AssignedUsername: t.GetAssignedUsername(), - Participants: participants, - StartDate: t.GetStartDate(), - LastMessageDate: t.GetLastMessageDate(), - LastMessageUserId: t.GetLastMessageUserId(), - LastMessageUsername: t.GetLastMessageUsername(), - LastModifiedDate: t.GetLastModifiedDate(), - ReplyCount: t.GetReplyCount(), - TotalMessageCount: t.GetTotalMessageCount(), - CustomFields: customFields, - ForumUrl: t.GetForumUrl(), - } -} - -// messagesFromProto maps datastore messages to the wire types (allocation -// discipline: [] on the wire, never null). -func messagesFromProto(in []*proto.Message) []*types.Message { - out := make([]*types.Message, 0, len(in)) - for _, m := range in { - out = append(out, &types.Message{ - MessageId: m.GetMessageId(), - TicketId: m.GetTicketId(), - UserId: m.GetUserId(), - Username: m.GetUsername(), - MessageDate: m.GetMessageDate(), - Message: m.GetMessage(), - MessageState: m.GetMessageState(), - Position: m.GetPosition(), - AttachCount: m.GetAttachCount(), - LastEditDate: m.GetLastEditDate(), - EditCount: m.GetEditCount(), - }) - } - return out -} - -// categoriesFromProto maps the datastore's proto-typed rows to the wire -// types (see ranksFromProto for the mapping-layer rationale and the -// allocation discipline). -func categoriesFromProto(in []*proto.Category) []*types.Category { - out := make([]*types.Category, 0, len(in)) - for _, c := range in { - out = append(out, &types.Category{ - CategoryId: c.GetCategoryId(), - Title: c.GetTitle(), - Description: c.GetDescription(), - ParentCategoryId: c.GetParentCategoryId(), - Depth: c.GetDepth(), - DisplayOrder: c.GetDisplayOrder(), - TicketCount: c.GetTicketCount(), - }) - } - return out -} From e8d202126b47dd9d63f63b3b6e432576e63228a0 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:39:10 -0400 Subject: [PATCH 3/8] test(stream-a): build types.* in fakes and retarget the golden harness Convert the rest fakes, the contract recording fake, and the datastore harness tests to build types.* so they satisfy the swapped Datastore interface. Drop the keycloak seed fields and the keycloak fake method along with them. Retarget the contract golden harness at rest.New: the gRPC server and the grpc-gateway are gone, so the corpus now replays directly against the stdlib net/http stack instead of dialing a gRPC server behind a gateway. The same goldens hold. Guard the roster handlers so a nil Profiles map still serializes as {"profiles":{}}, never null. The real datastore always allocates, but a nil map slipping through used to be caught by the old per-handler mapper; the guard keeps the frozen empty-result form. > *This was generated by AI* --- contract/fake_datastore_test.go | 177 +++++++++++++--------------- contract/harness_test.go | 123 +++++-------------- datastores/profiles_harness_test.go | 8 +- datastores/rosters_harness_test.go | 20 ++-- datastores/tickets_harness_test.go | 8 +- rest/awol_test.go | 4 +- rest/fake_positions_test.go | 18 +-- rest/fake_rosters_test.go | 64 +++++----- rest/fake_tickets_test.go | 42 +++---- rest/metrics_test.go | 6 +- rest/positions.go | 4 + rest/positions_test.go | 28 ++--- rest/rest_test.go | 121 ++++++++++--------- rest/rosters.go | 12 ++ rest/rosters_test.go | 24 ++-- rest/sentry_test.go | 28 ++--- rest/spec_test.go | 6 +- rest/tickets_test.go | 44 +++---- 18 files changed, 339 insertions(+), 398 deletions(-) diff --git a/contract/fake_datastore_test.go b/contract/fake_datastore_test.go index 87fbbdf..a994d61 100644 --- a/contract/fake_datastore_test.go +++ b/contract/fake_datastore_test.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" + "github.com/7cav/api/types" "gorm.io/gorm" ) @@ -33,9 +33,6 @@ var errOutage = errors.New("simulated datastore outage") // - Position search returns an empty (non-nil) LiteRoster for any query // that isn't an exact seeded title — mirroring the frozen #137 behavior // where plausible queries yield {"profiles":{}}. -// -// The keycloak lookup panics: the route is deliberately not recorded (#116) -// and the battery must never reach it. type recordingDatastore struct{} // --- API keys ---------------------------------------------------------- @@ -65,10 +62,10 @@ func scopeSet(scopes ...string) map[string]struct{} { // seedJarvis is the rich profile: every collection populated, relation id 1 // diverging from user id 3 (matches the live-capture divergence for Jarvis.A). -func seedJarvis() *proto.Profile { - return &proto.Profile{ - User: &proto.User{UserId: 3, Username: "Jarvis.A"}, - Rank: &proto.Rank{ +func seedJarvis() *types.Profile { + return &types.Profile{ + User: &types.User{UserId: 3, Username: "Jarvis.A"}, + Rank: &types.Rank{ RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618", @@ -76,26 +73,26 @@ func seedJarvis() *proto.Profile { }, RealName: "Adam Jarvis", UniformUrl: "https://7cav.us/data/roster_uniforms/0/1.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, - Primary: &proto.Position{PositionTitle: "Regimental Technical Aide", PositionId: 773}, - Secondaries: []*proto.Position{ + Roster: types.RosterTypeCombat, + Primary: &types.Position{PositionTitle: "Regimental Technical Aide", PositionId: 773}, + Secondaries: []*types.Position{ {PositionTitle: "S6 Web Developer", PositionId: 812}, }, - Records: []*proto.Record{ + Records: []*types.Record{ { RecordDetails: "Promoted to Major General (O-8)", - RecordType: proto.RecordType_RECORD_TYPE_PROMOTION, + RecordType: types.RecordTypePromotion, RecordDate: "2020-10-17", RecordUid: 46, }, { RecordDetails: "Completed 18th Combat Mission (Operation Pride of Charlie, Fall 2020)", - RecordType: proto.RecordType_RECORD_TYPE_OPERATION, + RecordType: types.RecordTypeOperation, RecordDate: "2020-09-27", RecordUid: 13863, }, }, - Awards: []*proto.Award{ + Awards: []*types.Award{ { AwardDetails: "For technical excellence & dedication ", AwardName: "Commendation Medal", @@ -106,7 +103,6 @@ func seedJarvis() *proto.Profile { }, JoinDate: "2014-02-08", PromotionDate: "2020-10-17", - KeycloakId: "3f8e2a10-dead-beef-cafe-0123456789ab", DiscordId: "112233445566778899", LastForumPostDate: "2026-05-30", Mos: "11B", @@ -117,25 +113,25 @@ func seedJarvis() *proto.Profile { // seedDoe is the sparse profile: unset nested messages stay nil (emitted as // null), collections stay empty (emitted as []), strings stay "" — the // emit-everything goldens hang off this profile. Relation 2 ↔ user 8. -func seedDoe() *proto.Profile { - return &proto.Profile{ - User: &proto.User{UserId: 8, Username: "John.Doe"}, - Rank: &proto.Rank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg", RankId: 22}, +func seedDoe() *types.Profile { + return &types.Profile{ + User: &types.User{UserId: 8, Username: "John.Doe"}, + Rank: &types.Rank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg", RankId: 22}, RealName: "John Doe", UniformUrl: "https://7cav.us/data/roster_uniforms/0/2.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, + Roster: types.RosterTypeCombat, Primary: nil, // → "primary": null - Secondaries: []*proto.Position{}, - Records: []*proto.Record{}, - Awards: []*proto.Award{}, + Secondaries: []*types.Position{}, + Records: []*types.Record{}, + Awards: []*types.Award{}, JoinDate: "2026-01-15", ConsoleGamertag: "CavGamer77", } } -func seedJarvisLite() *proto.LiteProfile { +func seedJarvisLite() *types.LiteProfile { j := seedJarvis() - return &proto.LiteProfile{ + return &types.LiteProfile{ User: j.User, Rank: j.Rank, RealName: j.RealName, @@ -145,7 +141,6 @@ func seedJarvisLite() *proto.LiteProfile { Secondaries: j.Secondaries, JoinDate: j.JoinDate, PromotionDate: j.PromotionDate, - KeycloakId: j.KeycloakId, DiscordId: j.DiscordId, AwardDate: "2021-03-01", RecordDate: "2020-10-17", @@ -154,28 +149,28 @@ func seedJarvisLite() *proto.LiteProfile { } } -func seedDoeLite() *proto.LiteProfile { +func seedDoeLite() *types.LiteProfile { d := seedDoe() - return &proto.LiteProfile{ + return &types.LiteProfile{ User: d.User, Rank: d.Rank, RealName: d.RealName, UniformUrl: d.UniformUrl, Roster: d.Roster, - Secondaries: []*proto.Position{}, + Secondaries: []*types.Position{}, JoinDate: d.JoinDate, ConsoleGamertag: d.ConsoleGamertag, } } -func (recordingDatastore) FindProfilesById(userIds ...uint64) ([]*proto.Profile, error) { +func (recordingDatastore) FindProfilesById(userIds ...uint64) ([]*types.Profile, error) { // Path value is the milpac relation key (see type comment). 777 is the // injected-outage id for the 500 golden. switch userIds[0] { case 1: - return []*proto.Profile{seedJarvis()}, nil + return []*types.Profile{seedJarvis()}, nil case 2: - return []*proto.Profile{seedDoe()}, nil + return []*types.Profile{seedDoe()}, nil case 777: return nil, errOutage default: @@ -183,114 +178,110 @@ func (recordingDatastore) FindProfilesById(userIds ...uint64) ([]*proto.Profile, } } -func (recordingDatastore) FindProfilesByUsername(username string) ([]*proto.Profile, error) { +func (recordingDatastore) FindProfilesByUsername(username string) ([]*types.Profile, error) { switch username { case "Jarvis.A": - return []*proto.Profile{seedJarvis()}, nil + return []*types.Profile{seedJarvis()}, nil case "John.Doe": - return []*proto.Profile{seedDoe()}, nil + return []*types.Profile{seedDoe()}, nil default: return nil, gorm.ErrRecordNotFound } } -func (recordingDatastore) FindProfileByDiscordID(discordId string) (*proto.Profile, error) { +func (recordingDatastore) FindProfileByDiscordID(discordId string) (*types.Profile, error) { if discordId == "112233445566778899" { return seedJarvis(), nil } return nil, gorm.ErrRecordNotFound } -func (recordingDatastore) FindProfileByGamertag(gamertag string) (*proto.Profile, error) { +func (recordingDatastore) FindProfileByGamertag(gamertag string) (*types.Profile, error) { if gamertag == "CavGamer77" { return seedDoe(), nil } return nil, gorm.ErrRecordNotFound } -func (recordingDatastore) FindProfileByKeycloakID(string) (*proto.Profile, error) { - panic("contract: keycloak route is deliberately not recorded (#116)") -} - // --- Rosters ------------------------------------------------------------ -func (recordingDatastore) FindRosterByType(t proto.RosterType) (*proto.Roster, error) { +func (recordingDatastore) FindRosterByType(t types.RosterType) (*types.Roster, error) { switch t { - case proto.RosterType_ROSTER_TYPE_COMBAT: - return &proto.Roster{Profiles: map[uint64]*proto.Profile{1: seedJarvis(), 2: seedDoe()}}, nil - case proto.RosterType_ROSTER_TYPE_ARLINGTON: + case types.RosterTypeCombat: + return &types.Roster{Profiles: map[uint64]*types.Profile{1: seedJarvis(), 2: seedDoe()}}, nil + case types.RosterTypeArlington: return nil, errOutage default: // Empty-but-present roster: {"profiles":{}}. - return &proto.Roster{Profiles: map[uint64]*proto.Profile{}}, nil + return &types.Roster{Profiles: map[uint64]*types.Profile{}}, nil } } -func (recordingDatastore) FindLiteRosterByType(t proto.RosterType) (*proto.LiteRoster, error) { +func (recordingDatastore) FindLiteRosterByType(t types.RosterType) (*types.LiteRoster, error) { switch t { - case proto.RosterType_ROSTER_TYPE_COMBAT: - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{1: seedJarvisLite(), 2: seedDoeLite()}}, nil + case types.RosterTypeCombat: + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{1: seedJarvisLite(), 2: seedDoeLite()}}, nil default: - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{}}, nil } } -func (recordingDatastore) FindS1UniformsRosterByType(t proto.RosterType) (*proto.S1UniformsRoster, error) { - if t != proto.RosterType_ROSTER_TYPE_COMBAT { - return &proto.S1UniformsRoster{Profiles: map[uint64]*proto.S1UniformsProfile{}}, nil +func (recordingDatastore) FindS1UniformsRosterByType(t types.RosterType) (*types.S1UniformsRoster, error) { + if t != types.RosterTypeCombat { + return &types.S1UniformsRoster{Profiles: map[uint64]*types.S1UniformsProfile{}}, nil } - return &proto.S1UniformsRoster{Profiles: map[uint64]*proto.S1UniformsProfile{ + return &types.S1UniformsRoster{Profiles: map[uint64]*types.S1UniformsProfile{ 1: { - User: &proto.User{UserId: 3, Username: "Jarvis.A"}, - Rank: &proto.S1UniformsRank{RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618"}, + User: &types.User{UserId: 3, Username: "Jarvis.A"}, + Rank: &types.S1UniformsRank{RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618"}, RealName: "Adam Jarvis", UniformUrl: "https://7cav.us/data/roster_uniforms/0/1.jpg", UniformDate: "2025-11-02", UniformUpdateTriggerDate: "2025-12-01", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, + Roster: types.RosterTypeCombat, PrimaryPositionTitle: "Regimental Technical Aide", - Secondaries: []*proto.S1UniformsPosition{{PositionTitle: "S6 Web Developer"}}, + Secondaries: []*types.S1UniformsPosition{{PositionTitle: "S6 Web Developer"}}, JoinDate: "2014-02-08", PromotionDate: "2020-10-17", AreaOfResponsibility: "S6", }, 2: { - User: &proto.User{UserId: 8, Username: "John.Doe"}, - Rank: &proto.S1UniformsRank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg"}, + User: &types.User{UserId: 8, Username: "John.Doe"}, + Rank: &types.S1UniformsRank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg"}, RealName: "John Doe", UniformUrl: "https://7cav.us/data/roster_uniforms/0/2.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, + Roster: types.RosterTypeCombat, PrimaryPositionTitle: "Rifleman", - Secondaries: []*proto.S1UniformsPosition{}, + Secondaries: []*types.S1UniformsPosition{}, JoinDate: "2026-01-15", }, }}, nil } -func (recordingDatastore) FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, error) { +func (recordingDatastore) FindProfilesByPosition(positionQuery string) (*types.LiteRoster, error) { if positionQuery == "Regimental Technical Aide" { - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{1: seedJarvisLite()}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{1: seedJarvisLite()}}, nil } // Frozen #137 behavior: plausible queries come back empty, not 404. - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{}}, nil } // --- Reference lists ------------------------------------------------------ -func (recordingDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { - return []*proto.RankExpanded{ +func (recordingDatastore) FindAllRanks() ([]*types.RankExpanded, error) { + return []*types.RankExpanded{ {RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618", RankId: 4, RankDisplayOrder: 4}, {RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg", RankId: 22, RankDisplayOrder: 22}, }, nil } -func (recordingDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { - return []*proto.PositionGroup{ +func (recordingDatastore) FindAllPositionGroups() ([]*types.PositionGroup, error) { + return []*types.PositionGroup{ { GroupId: 9, GroupTitle: "Regimental HQ", GroupDisplayOrder: 1, - Positions: []*proto.PositionExpanded{ + Positions: []*types.PositionExpanded{ {PositionTitle: "Regimental Technical Aide", PositionId: 773, PositionDisplayOrder: 3, PositionPossibleSecondary: false}, {PositionTitle: "S6 Web Developer", PositionId: 812, PositionDisplayOrder: 7, PositionPossibleSecondary: true}, }, @@ -298,8 +289,8 @@ func (recordingDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error }, nil } -func (recordingDatastore) FindAwol() ([]*proto.Awol, error) { - return []*proto.Awol{ +func (recordingDatastore) FindAwol() ([]*types.Awol, error) { + return []*types.Awol{ { GroupName: "Alpha Company", RankName: "Private", @@ -317,8 +308,8 @@ func (recordingDatastore) FindAwol() ([]*proto.Awol, error) { // seedTickets returns the ticket world sorted by last_modified_date DESC, // ticket_id DESC — the order the real datastore queries in. -func seedTickets() []*proto.Ticket { - return []*proto.Ticket{ +func seedTickets() []*types.Ticket { + return []*types.Ticket{ { TicketId: 44, TicketRef: "ZZTOP44Q", @@ -339,7 +330,7 @@ func seedTickets() []*proto.Ticket { StarterUsername: "John.Doe", AssignedUserId: 3, AssignedUsername: "Jarvis.A", - Participants: []*proto.TicketParticipant{ + Participants: []*types.TicketParticipant{ {UserId: 8, LastReadDate: 1748690000}, {UserId: 3, LastReadDate: 1748695000}, }, @@ -368,7 +359,7 @@ func seedTickets() []*proto.Ticket { DiscussionState: "visible", StarterUserId: 3, StarterUsername: "Jarvis.A", - Participants: []*proto.TicketParticipant{}, + Participants: []*types.TicketParticipant{}, StartDate: 1748500000, LastMessageDate: 1748590000, LastMessageUserId: 3, @@ -396,7 +387,7 @@ func seedTickets() []*proto.Ticket { DiscussionState: "visible", StarterUserId: 8, StarterUsername: "John.Doe", - Participants: []*proto.TicketParticipant{{UserId: 8, LastReadDate: 1748450000}}, + Participants: []*types.TicketParticipant{{UserId: 8, LastReadDate: 1748450000}}, StartDate: 1748400000, LastMessageDate: 1748480000, LastMessageUserId: 8, @@ -411,8 +402,8 @@ func seedTickets() []*proto.Ticket { } // seedMessages42 is the message thread for ticket 42, position ascending. -func seedMessages42() []*proto.Message { - return []*proto.Message{ +func seedMessages42() []*types.Message { + return []*types.Message{ { MessageId: 9001, TicketId: 42, @@ -494,7 +485,7 @@ func decodeMessageCursor(c string) (uint32, error) { return uint32(pos), nil } -func (recordingDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { +func (recordingDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, f *datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { perPage := f.PerPage if perPage == 0 { perPage = 50 @@ -512,7 +503,7 @@ func (recordingDatastore) ListTickets(_ context.Context, _ datastores.TicketRefe } } - var rows []*proto.Ticket + var rows []*types.Ticket for _, t := range seedTickets() { if hasCursor && !(t.LastModifiedDate < afterTs || (t.LastModifiedDate == afterTs && t.TicketId < afterId)) { continue @@ -533,12 +524,12 @@ func (recordingDatastore) ListTickets(_ context.Context, _ datastores.TicketRefe next = encodeTicketCursor(last.LastModifiedDate, last.TicketId) } if rows == nil { - rows = []*proto.Ticket{} + rows = []*types.Ticket{} } return rows, next, hasMore, nil } -func matchTicket(t *proto.Ticket, f *datastores.ListTicketsFilter) bool { +func matchTicket(t *types.Ticket, f *datastores.ListTicketsFilter) bool { if len(f.CategoryIDs) > 0 { match := containsU32(f.CategoryIDs, t.CategoryId) if !match && !f.ExcludeSubcategories { @@ -592,7 +583,7 @@ func containsStr(haystack []string, needle string) bool { return false } -func (recordingDatastore) GetTicket(_ context.Context, _ datastores.TicketReferenceCache, ticketID uint32, _ string) (*proto.Ticket, error) { +func (recordingDatastore) GetTicket(_ context.Context, _ datastores.TicketReferenceCache, ticketID uint32, _ string) (*types.Ticket, error) { for _, t := range seedTickets() { if t.TicketId == ticketID { return t, nil @@ -601,7 +592,7 @@ func (recordingDatastore) GetTicket(_ context.Context, _ datastores.TicketRefere return nil, gorm.ErrRecordNotFound } -func (recordingDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketReferenceCache, ref string, _ string) (*proto.Ticket, error) { +func (recordingDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketReferenceCache, ref string, _ string) (*types.Ticket, error) { for _, t := range seedTickets() { if t.TicketRef == ref { return t, nil @@ -610,9 +601,9 @@ func (recordingDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketR return nil, gorm.ErrRecordNotFound } -func (recordingDatastore) GetTicketFirstMessages(_ context.Context, ticketID uint32, n int, _ bool) ([]*proto.Message, uint32, error) { +func (recordingDatastore) GetTicketFirstMessages(_ context.Context, ticketID uint32, n int, _ bool) ([]*types.Message, uint32, error) { if ticketID != 42 { - return []*proto.Message{}, 0, nil + return []*types.Message{}, 0, nil } msgs := seedMessages42() total := uint32(len(msgs)) @@ -622,7 +613,7 @@ func (recordingDatastore) GetTicketFirstMessages(_ context.Context, ticketID uin return msgs, total, nil } -func (recordingDatastore) ListTicketMessages(_ context.Context, ticketID uint32, afterCursor string, perPage uint32, _ bool) ([]*proto.Message, string, bool, error) { +func (recordingDatastore) ListTicketMessages(_ context.Context, ticketID uint32, afterCursor string, perPage uint32, _ bool) ([]*types.Message, string, bool, error) { from, err := decodeMessageCursor(afterCursor) if err != nil { return nil, "", false, err @@ -632,7 +623,7 @@ func (recordingDatastore) ListTicketMessages(_ context.Context, ticketID uint32, } else if perPage > 100 { perPage = 100 } - var rows []*proto.Message + var rows []*types.Message if ticketID == 42 { for _, m := range seedMessages42() { if m.Position >= from { @@ -649,13 +640,13 @@ func (recordingDatastore) ListTicketMessages(_ context.Context, ticketID uint32, next = encodeMessageCursor(rows[len(rows)-1].Position + 1) } if rows == nil { - rows = []*proto.Message{} + rows = []*types.Message{} } return rows, next, hasMore, nil } -func (recordingDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*proto.Category, error) { - return []*proto.Category{ +func (recordingDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*types.Category, error) { + return []*types.Category{ {CategoryId: 1, Title: "Recruiting", Description: "Enlistment & recruiting questions", ParentCategoryId: 0, Depth: 0, DisplayOrder: 10, TicketCount: 120}, {CategoryId: 5, Title: "S1 Personnel", Description: "", ParentCategoryId: 1, Depth: 1, DisplayOrder: 20, TicketCount: 34}, {CategoryId: 7, Title: "S6 Technical Support", Description: "Teamspeak, forum & game-server help", ParentCategoryId: 0, Depth: 0, DisplayOrder: 30, TicketCount: 78}, diff --git a/contract/harness_test.go b/contract/harness_test.go index d6ea4e5..3e26132 100644 --- a/contract/harness_test.go +++ b/contract/harness_test.go @@ -1,20 +1,14 @@ package contract import ( - "fmt" "io" - "net" "net/http" "os" "testing" - "time" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" + "github.com/7cav/api/referencecache" "github.com/7cav/api/rest" - "github.com/7cav/api/servers/gateway" - grpcServices "github.com/7cav/api/servers/grpc" - "google.golang.org/grpc" ) // Compile-time guarantee the recording fake implements the full interface. @@ -23,29 +17,29 @@ var _ datastores.Datastore = recordingDatastore{} var ( stackHandler http.Handler stackErr error - // grpcServeErr receives the srv.Serve result if Serve ever returns - // (buffered 1, written exactly once). Peeked via pendingServeErr so a - // startup or mid-run server death is reported instead of discarded. - grpcServeErr chan error ) -// TestMain mounts the CURRENT production stack in-process exactly once: +// TestMain mounts the production stack in-process exactly once: // -// httptest request → gateway.Service.Server().Handler (real /api routing, -// real auth middleware, real sentry/compression chain) → real gRPC -// client conn over TCP → real grpc.Server with the production interceptor -// chain (auth outer, sentry inner; mirrors servers.apiUnaryInterceptors) -// → MilpacsService + TicketsService handlers → recordingDatastore. +// httptest request → rest.New(ds, rc).ServeHTTP (real /api routing, real +// auth middleware, real sentry/gzip chain) → milpacs/tickets handlers → +// recordingDatastore. +// +// Since the single-listener cutover (#134) there is one stack: the stdlib +// net/http rest package. The gRPC server and the grpc-gateway translation +// layer are gone, so the harness mounts rest.New directly instead of dialing a +// gRPC server behind a gateway. // // Differences from production, all behavior-neutral by construction: // - the datastore is the seeded fake (no MySQL), -// - SENTRY_DSN is unset (TestMain enforces it), so both sentry layers are -// pass-throughs, -// - the TicketsService reference cache is nil — the fake never touches it. +// - SENTRY_DSN is unset (TestMain enforces it), so the sentry layer is a +// pass-through, +// - the TicketReferenceCache is the no-op stub below — the fake bakes its +// reference-name resolution into the seeds and never consults the cache. // // There is no Redis anywhere: the response cache was deleted at Phase 2 -// de-cache (#123 middleware, #124 package + Redis), so the stack mounts and -// serves with no cache backend at all — exactly like production. +// de-cache (#123 middleware, #124 package + Redis), so the stack serves with +// no cache backend at all, exactly like production. func TestMain(m *testing.M) { quietProductionLoggers() os.Unsetenv("SENTRY_DSN") // make the pass-through claim above true by construction @@ -55,92 +49,37 @@ func TestMain(m *testing.M) { func currentStack(t *testing.T) http.Handler { t.Helper() - if stackErr == nil { - if err := pendingServeErr(); err != nil { - stackErr = fmt.Errorf("grpc server exited mid-run: %w", err) - } - } if stackErr != nil { t.Fatalf("mounting current stack: %v", stackErr) } return stackHandler } -// pendingServeErr non-blockingly peeks at grpcServeErr and puts any value -// back, so the one Serve result stays observable by every later caller. -func pendingServeErr() error { - select { - case err := <-grpcServeErr: - grpcServeErr <- err - return err - default: - return nil - } -} - func mountCurrentStack() (http.Handler, error) { ds := recordingDatastore{} + return rest.New(ds, stubReferenceCache{}), nil +} - lis, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, fmt.Errorf("grpc listen: %w", err) - } - - // Production interceptor chain order (servers.apiUnaryInterceptors): - // auth outer, sentry inner. - srv := grpc.NewServer(grpc.ChainUnaryInterceptor( - grpcServices.NewAuthInterceptor(ds), - grpcServices.NewSentryInterceptor(), - )) - proto.RegisterMilpacServiceServer(srv, &grpcServices.MilpacsService{Datastore: ds}) - proto.RegisterTicketsServiceServer(srv, &grpcServices.TicketsService{Datastore: ds}) - grpcServeErr = make(chan error, 1) - go func() { - grpcServeErr <- srv.Serve(lis) - }() +// stubReferenceCache is the no-op TicketReferenceCache the corpus mounts: +// rest.New refuses nil, and recordingDatastore resolves all reference names in +// its seeds (it never calls back into the cache), so every method can return +// the zero value. +type stubReferenceCache struct{} - svc := gateway.Service{ - Address: lis.Addr().String(), - Datastore: ds, - } - // gateway.Service.Server() dials with grpc.WithBlock() on - // context.Background() — an unbounded blocking dial. If the gRPC server - // never came up, that dial would hang TestMain forever with zero output, - // so run the mount in a goroutine and watchdog it: finish, see the Serve - // error, or time out — never hang silently. - const mountTimeout = 10 * time.Second - mounted := make(chan *http.Server, 1) - go func() { - mounted <- svc.Server() - }() - var httpSrv *http.Server - select { - case httpSrv = <-mounted: - case err := <-grpcServeErr: - grpcServeErr <- err // keep it observable for pendingServeErr - return nil, fmt.Errorf("grpc server exited before gateway mount; serve error: %v", err) - case <-time.After(mountTimeout): - return nil, fmt.Errorf("grpc dial did not become ready in %s; serve error: %v", mountTimeout, pendingServeErr()) - } - if httpSrv == nil { - return nil, fmt.Errorf("gateway.Service.Server() returned nil (dial or registration failure)") - } - return httpSrv.Handler, nil -} +func (stubReferenceCache) StatusName(uint32) string { return "" } +func (stubReferenceCache) PriorityName(uint32) string { return "" } +func (stubReferenceCache) PrefixName(uint32) string { return "" } +func (stubReferenceCache) Category(uint32) *referencecache.CategoryRecord { return nil } +func (stubReferenceCache) CategoryAncestors(uint32) []uint32 { return nil } +func (stubReferenceCache) CategoryTree() []*referencecache.CategoryRecord { return nil } +func (stubReferenceCache) ExpandSubtree(ids []uint32) []uint32 { return ids } // quietProductionLoggers silences the chatty Info/Warn loggers of the stack // under test so corpus runs stay readable. Errors stay visible. -// -// Do NOT silence gateway.Error (or the grpc Error loggers): gateway.Service. -// Server() reports dial/registration failure only via its Error log plus a -// nil return, so muting Error would reduce a mount failure to a bare -// "returned nil" with no cause. func quietProductionLoggers() { for _, l := range []interface{ SetOutput(io.Writer) }{ - gateway.Info, gateway.Warn, - grpcServices.Info, grpcServices.Warn, datastores.Info, datastores.Warn, - rest.Info, rest.Warn, // the gateway delegates auth/gzip to rest (#125) + rest.Info, rest.Warn, } { l.SetOutput(io.Discard) } diff --git a/datastores/profiles_harness_test.go b/datastores/profiles_harness_test.go index 217ba2a..660fa2e 100644 --- a/datastores/profiles_harness_test.go +++ b/datastores/profiles_harness_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/7cav/api/proto" + "github.com/7cav/api/types" "gorm.io/gorm" ) @@ -73,7 +73,7 @@ func TestFindProfilesById_ReturnsFullProfileShape(t *testing.T) { if got.UniformUrl != "https://7cav.us/data/roster_uniforms/0/205.jpg" { t.Errorf("UniformUrl = %q", got.UniformUrl) } - if got.Roster != proto.RosterType_ROSTER_TYPE_COMBAT { + if got.Roster != types.RosterTypeCombat { t.Errorf("Roster = %v, want COMBAT", got.Roster) } if got.Primary.PositionId != 10 || got.Primary.PositionTitle != "Rifleman" { @@ -101,7 +101,7 @@ func TestFindProfilesById_ReturnsFullProfileShape(t *testing.T) { if len(got.Records) != 2 { t.Fatalf("expected 2 service records, got %d", len(got.Records)) } - byUID := map[uint64]*proto.Record{} + byUID := map[uint64]*types.Record{} for _, r := range got.Records { byUID[r.RecordUid] = r } @@ -109,7 +109,7 @@ func TestFindProfilesById_ReturnsFullProfileShape(t *testing.T) { if promo == nil { t.Fatal("expected service record uid 1004") } - if promo.RecordType != proto.RecordType_RECORD_TYPE_PROMOTION { + if promo.RecordType != types.RecordTypePromotion { t.Errorf("record 1004 type = %v, want PROMOTION", promo.RecordType) } if promo.RecordDetails != "Promoted to Specialist" { diff --git a/datastores/rosters_harness_test.go b/datastores/rosters_harness_test.go index c76d137..1d85ec4 100644 --- a/datastores/rosters_harness_test.go +++ b/datastores/rosters_harness_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/7cav/api/proto" + "github.com/7cav/api/types" ) // dateTime mirrors the lite/uniform timestamp convention: unix rendered @@ -20,7 +20,7 @@ func dateTime(unix int64) string { func TestFindRosterByType_CombatRosterKeyedByRelationId(t *testing.T) { ds := openHarnessDatastore(t) - roster, err := ds.FindRosterByType(proto.RosterType_ROSTER_TYPE_COMBAT) + roster, err := ds.FindRosterByType(types.RosterTypeCombat) if err != nil { t.Fatalf("FindRosterByType(COMBAT): %v", err) } @@ -46,7 +46,7 @@ func TestFindRosterByType_CombatRosterKeyedByRelationId(t *testing.T) { } // Roster membership separates the shapes: reserve and past members. - reserve, err := ds.FindRosterByType(proto.RosterType_ROSTER_TYPE_RESERVE) + reserve, err := ds.FindRosterByType(types.RosterTypeReserve) if err != nil { t.Fatalf("FindRosterByType(RESERVE): %v", err) } @@ -54,7 +54,7 @@ func TestFindRosterByType_CombatRosterKeyedByRelationId(t *testing.T) { t.Errorf("reserve roster: want exactly relations 320 and 340, got %v", profileKeys(reserve.Profiles)) } - past, err := ds.FindRosterByType(proto.RosterType_ROSTER_TYPE_PAST_MEMBERS) + past, err := ds.FindRosterByType(types.RosterTypePastMembers) if err != nil { t.Fatalf("FindRosterByType(PAST_MEMBERS): %v", err) } @@ -72,7 +72,7 @@ func TestFindRosterByType_CombatRosterKeyedByRelationId(t *testing.T) { func TestFindRosterByType_EmptyRosterIsNotAnError(t *testing.T) { ds := openHarnessDatastore(t) - roster, err := ds.FindRosterByType(proto.RosterType_ROSTER_TYPE_ELOA) + roster, err := ds.FindRosterByType(types.RosterTypeEloa) if err != nil { t.Fatalf("FindRosterByType(ELOA): %v", err) } @@ -87,7 +87,7 @@ func TestFindRosterByType_EmptyRosterIsNotAnError(t *testing.T) { func TestFindLiteRosterByType_LiteShapeWithActivityDates(t *testing.T) { ds := openHarnessDatastore(t) - roster, err := ds.FindLiteRosterByType(proto.RosterType_ROSTER_TYPE_COMBAT) + roster, err := ds.FindLiteRosterByType(types.RosterTypeCombat) if err != nil { t.Fatalf("FindLiteRosterByType(COMBAT): %v", err) } @@ -124,7 +124,7 @@ func TestFindLiteRosterByType_LiteShapeWithActivityDates(t *testing.T) { func TestFindLiteRosterByType_NeverPostedMemberHasEmptyLastPostDate(t *testing.T) { ds := openHarnessDatastore(t) - roster, err := ds.FindLiteRosterByType(proto.RosterType_ROSTER_TYPE_PAST_MEMBERS) + roster, err := ds.FindLiteRosterByType(types.RosterTypePastMembers) if err != nil { t.Fatalf("FindLiteRosterByType(PAST_MEMBERS): %v", err) } @@ -143,7 +143,7 @@ func TestFindLiteRosterByType_NeverPostedMemberHasEmptyLastPostDate(t *testing.T func TestFindS1UniformsRosterByType_UniformsShape(t *testing.T) { ds := openHarnessDatastore(t) - roster, err := ds.FindS1UniformsRosterByType(proto.RosterType_ROSTER_TYPE_COMBAT) + roster, err := ds.FindS1UniformsRosterByType(types.RosterTypeCombat) if err != nil { t.Fatalf("FindS1UniformsRosterByType(COMBAT): %v", err) } @@ -194,7 +194,7 @@ func TestFindS1UniformsRosterByType_UniformsShape(t *testing.T) { } // Reserve roster: the ELOA position group maps to ELOA. - reserve, err := ds.FindS1UniformsRosterByType(proto.RosterType_ROSTER_TYPE_RESERVE) + reserve, err := ds.FindS1UniformsRosterByType(types.RosterTypeReserve) if err != nil { t.Fatalf("FindS1UniformsRosterByType(RESERVE): %v", err) } @@ -293,7 +293,7 @@ func TestFindAwol_FlagsStalePostersOnActiveRosters(t *testing.T) { // Keyed by forum USER id (Awol.UserId) — relation ids live in // Awol.MilpacId and must not be used as exclusion keys here. - byUser := map[uint64]*proto.Awol{} + byUser := map[uint64]*types.Awol{} for _, a := range awols { byUser[a.UserId] = a } diff --git a/datastores/tickets_harness_test.go b/datastores/tickets_harness_test.go index 047c6c6..dbbd449 100644 --- a/datastores/tickets_harness_test.go +++ b/datastores/tickets_harness_test.go @@ -8,14 +8,14 @@ import ( "testing" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" + "github.com/7cav/api/types" "github.com/spf13/viper" "gorm.io/gorm" ) // ticketIDs flattens a ticket slice to ids, preserving order, for // readable ordering assertions. -func ticketIDs(tickets []*proto.Ticket) []uint32 { +func ticketIDs(tickets []*types.Ticket) []uint32 { ids := make([]uint32, len(tickets)) for i, t := range tickets { ids[i] = t.TicketId @@ -23,7 +23,7 @@ func ticketIDs(tickets []*proto.Ticket) []uint32 { return ids } -func assertTicketOrder(t *testing.T, tickets []*proto.Ticket, want []uint32) { +func assertTicketOrder(t *testing.T, tickets []*types.Ticket, want []uint32) { t.Helper() got := ticketIDs(tickets) if len(got) != len(want) { @@ -561,7 +561,7 @@ func TestListCategories_ServesCategoryTree(t *testing.T) { } } -func messagePositions(msgs []*proto.Message) []uint32 { +func messagePositions(msgs []*types.Message) []uint32 { out := make([]uint32, len(msgs)) for i, m := range msgs { out[i] = m.Position diff --git a/rest/awol_test.go b/rest/awol_test.go index 672304a..ccead89 100644 --- a/rest/awol_test.go +++ b/rest/awol_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/7cav/api/proto" "github.com/7cav/api/rest" + "github.com/7cav/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,7 +16,7 @@ import ( // witness populated. Empty is also this route's NORMAL state: nobody AWOL is // the healthy regiment, so the empty form matters more here than anywhere. func TestNewStack_EmptyAwolListIsEmptyArray(t *testing.T) { - h := rest.New(&fakeDatastore{findAwol: func() ([]*proto.Awol, error) { + h := rest.New(&fakeDatastore{findAwol: func() ([]*types.Awol, error) { return nil, nil }}, &stubReferenceCache{}) diff --git a/rest/fake_positions_test.go b/rest/fake_positions_test.go index 0a4baa5..d54c820 100644 --- a/rest/fake_positions_test.go +++ b/rest/fake_positions_test.go @@ -9,19 +9,19 @@ package rest_test // one AWOL row). import ( - "github.com/7cav/api/proto" + "github.com/7cav/api/types" ) -func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { +func (f *fakeDatastore) FindAllPositionGroups() ([]*types.PositionGroup, error) { if f.findAllPositionGroups != nil { return f.findAllPositionGroups() } - return []*proto.PositionGroup{ + return []*types.PositionGroup{ { GroupId: 9, GroupTitle: "Regimental HQ", GroupDisplayOrder: 1, - Positions: []*proto.PositionExpanded{ + Positions: []*types.PositionExpanded{ {PositionTitle: "Regimental Technical Aide", PositionId: 773, PositionDisplayOrder: 3, PositionPossibleSecondary: false}, {PositionTitle: "S6 Web Developer", PositionId: 812, PositionDisplayOrder: 7, PositionPossibleSecondary: true}, }, @@ -29,22 +29,22 @@ func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) }, nil } -func (f *fakeDatastore) FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, error) { +func (f *fakeDatastore) FindProfilesByPosition(positionQuery string) (*types.LiteRoster, error) { if f.findProfilesByPosition != nil { return f.findProfilesByPosition(positionQuery) } if positionQuery == "Regimental Technical Aide" { - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{1: seedJarvisLite()}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{1: seedJarvisLite()}}, nil } // Frozen #137 behavior: plausible queries come back empty, not 404. - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{}}, nil } -func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { +func (f *fakeDatastore) FindAwol() ([]*types.Awol, error) { if f.findAwol != nil { return f.findAwol() } - return []*proto.Awol{ + return []*types.Awol{ { GroupName: "Alpha Company", RankName: "Private", diff --git a/rest/fake_rosters_test.go b/rest/fake_rosters_test.go index 18d723f..16e6365 100644 --- a/rest/fake_rosters_test.go +++ b/rest/fake_rosters_test.go @@ -1,7 +1,7 @@ package rest_test import ( - "github.com/7cav/api/proto" + "github.com/7cav/api/types" ) // Roster seeds for the golden replay, mirroring the recording seed @@ -10,53 +10,52 @@ import ( // injected outage on the FULL roster route, and every other roster is // empty-but-present ({"profiles":{}} on the wire). -func (f *fakeDatastore) FindRosterByType(t proto.RosterType) (*proto.Roster, error) { +func (f *fakeDatastore) FindRosterByType(t types.RosterType) (*types.Roster, error) { if f.findRosterByType != nil { return f.findRosterByType(t) } switch t { - case proto.RosterType_ROSTER_TYPE_COMBAT: - return &proto.Roster{Profiles: map[uint64]*proto.Profile{1: seedJarvis(), 2: seedDoe()}}, nil - case proto.RosterType_ROSTER_TYPE_ARLINGTON: + case types.RosterTypeCombat: + return &types.Roster{Profiles: map[uint64]*types.Profile{1: seedJarvis(), 2: seedDoe()}}, nil + case types.RosterTypeArlington: return nil, errOutage default: - return &proto.Roster{Profiles: map[uint64]*proto.Profile{}}, nil + return &types.Roster{Profiles: map[uint64]*types.Profile{}}, nil } } -func (f *fakeDatastore) FindLiteRosterByType(t proto.RosterType) (*proto.LiteRoster, error) { +func (f *fakeDatastore) FindLiteRosterByType(t types.RosterType) (*types.LiteRoster, error) { if f.findLiteRosterByType != nil { return f.findLiteRosterByType(t) } switch t { - case proto.RosterType_ROSTER_TYPE_COMBAT: - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{1: seedJarvisLite(), 2: seedDoeLite()}}, nil + case types.RosterTypeCombat: + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{1: seedJarvisLite(), 2: seedDoeLite()}}, nil default: - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{}}, nil } } -func (f *fakeDatastore) FindS1UniformsRosterByType(t proto.RosterType) (*proto.S1UniformsRoster, error) { +func (f *fakeDatastore) FindS1UniformsRosterByType(t types.RosterType) (*types.S1UniformsRoster, error) { if f.findS1UniformsRosterByType != nil { return f.findS1UniformsRosterByType(t) } switch t { - case proto.RosterType_ROSTER_TYPE_COMBAT: - return &proto.S1UniformsRoster{Profiles: map[uint64]*proto.S1UniformsProfile{ + case types.RosterTypeCombat: + return &types.S1UniformsRoster{Profiles: map[uint64]*types.S1UniformsProfile{ 1: seedJarvisS1Uniforms(), 2: seedDoeS1Uniforms(), }}, nil default: - return &proto.S1UniformsRoster{Profiles: map[uint64]*proto.S1UniformsProfile{}}, nil + return &types.S1UniformsRoster{Profiles: map[uint64]*types.S1UniformsProfile{}}, nil } } // seedJarvisLite mirrors the recording seed's lite view of the rich profile: // the records/awards collections collapse to the two scalar summary dates. -// KeycloakId stays set so the mapper dropping it is observable in the replay. -func seedJarvisLite() *proto.LiteProfile { +func seedJarvisLite() *types.LiteProfile { j := seedJarvis() - return &proto.LiteProfile{ + return &types.LiteProfile{ User: j.User, Rank: j.Rank, RealName: j.RealName, @@ -66,7 +65,6 @@ func seedJarvisLite() *proto.LiteProfile { Secondaries: j.Secondaries, JoinDate: j.JoinDate, PromotionDate: j.PromotionDate, - KeycloakId: j.KeycloakId, DiscordId: j.DiscordId, AwardDate: "2021-03-01", RecordDate: "2020-10-17", @@ -77,15 +75,15 @@ func seedJarvisLite() *proto.LiteProfile { // seedDoeLite mirrors the recording seed's sparse lite profile: unset nested // messages nil, collections empty, summary dates "". -func seedDoeLite() *proto.LiteProfile { +func seedDoeLite() *types.LiteProfile { d := seedDoe() - return &proto.LiteProfile{ + return &types.LiteProfile{ User: d.User, Rank: d.Rank, RealName: d.RealName, UniformUrl: d.UniformUrl, Roster: d.Roster, - Secondaries: []*proto.Position{}, + Secondaries: []*types.Position{}, JoinDate: d.JoinDate, ConsoleGamertag: d.ConsoleGamertag, } @@ -94,32 +92,32 @@ func seedDoeLite() *proto.LiteProfile { // seedJarvisS1Uniforms mirrors the recording seed's S1 uniforms view: the // tool-specific shape (S1UniformsRank without rankId, title-only secondary // positions, the uniform dates and area of responsibility). -func seedJarvisS1Uniforms() *proto.S1UniformsProfile { - return &proto.S1UniformsProfile{ - User: &proto.User{UserId: 3, Username: "Jarvis.A"}, - Rank: &proto.S1UniformsRank{RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618"}, +func seedJarvisS1Uniforms() *types.S1UniformsProfile { + return &types.S1UniformsProfile{ + User: &types.User{UserId: 3, Username: "Jarvis.A"}, + Rank: &types.S1UniformsRank{RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618"}, RealName: "Adam Jarvis", UniformUrl: "https://7cav.us/data/roster_uniforms/0/1.jpg", UniformDate: "2025-11-02", UniformUpdateTriggerDate: "2025-12-01", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, + Roster: types.RosterTypeCombat, PrimaryPositionTitle: "Regimental Technical Aide", - Secondaries: []*proto.S1UniformsPosition{{PositionTitle: "S6 Web Developer"}}, + Secondaries: []*types.S1UniformsPosition{{PositionTitle: "S6 Web Developer"}}, JoinDate: "2014-02-08", PromotionDate: "2020-10-17", AreaOfResponsibility: "S6", } } -func seedDoeS1Uniforms() *proto.S1UniformsProfile { - return &proto.S1UniformsProfile{ - User: &proto.User{UserId: 8, Username: "John.Doe"}, - Rank: &proto.S1UniformsRank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg"}, +func seedDoeS1Uniforms() *types.S1UniformsProfile { + return &types.S1UniformsProfile{ + User: &types.User{UserId: 8, Username: "John.Doe"}, + Rank: &types.S1UniformsRank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg"}, RealName: "John Doe", UniformUrl: "https://7cav.us/data/roster_uniforms/0/2.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, + Roster: types.RosterTypeCombat, PrimaryPositionTitle: "Rifleman", - Secondaries: []*proto.S1UniformsPosition{}, + Secondaries: []*types.S1UniformsPosition{}, JoinDate: "2026-01-15", } } diff --git a/rest/fake_tickets_test.go b/rest/fake_tickets_test.go index 046866c..cb848c0 100644 --- a/rest/fake_tickets_test.go +++ b/rest/fake_tickets_test.go @@ -17,14 +17,14 @@ import ( "strings" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" + "github.com/7cav/api/types" "gorm.io/gorm" ) // seedTickets returns the ticket world sorted by last_modified_date DESC, // ticket_id DESC — the order the real datastore queries in. -func seedTickets() []*proto.Ticket { - return []*proto.Ticket{ +func seedTickets() []*types.Ticket { + return []*types.Ticket{ { TicketId: 44, TicketRef: "ZZTOP44Q", @@ -45,7 +45,7 @@ func seedTickets() []*proto.Ticket { StarterUsername: "John.Doe", AssignedUserId: 3, AssignedUsername: "Jarvis.A", - Participants: []*proto.TicketParticipant{ + Participants: []*types.TicketParticipant{ {UserId: 8, LastReadDate: 1748690000}, {UserId: 3, LastReadDate: 1748695000}, }, @@ -74,7 +74,7 @@ func seedTickets() []*proto.Ticket { DiscussionState: "visible", StarterUserId: 3, StarterUsername: "Jarvis.A", - Participants: []*proto.TicketParticipant{}, + Participants: []*types.TicketParticipant{}, StartDate: 1748500000, LastMessageDate: 1748590000, LastMessageUserId: 3, @@ -102,7 +102,7 @@ func seedTickets() []*proto.Ticket { DiscussionState: "visible", StarterUserId: 8, StarterUsername: "John.Doe", - Participants: []*proto.TicketParticipant{{UserId: 8, LastReadDate: 1748450000}}, + Participants: []*types.TicketParticipant{{UserId: 8, LastReadDate: 1748450000}}, StartDate: 1748400000, LastMessageDate: 1748480000, LastMessageUserId: 8, @@ -117,8 +117,8 @@ func seedTickets() []*proto.Ticket { } // seedMessages42 is the message thread for ticket 42, position ascending. -func seedMessages42() []*proto.Message { - return []*proto.Message{ +func seedMessages42() []*types.Message { + return []*types.Message{ { MessageId: 9001, TicketId: 42, @@ -200,7 +200,7 @@ func decodeMessageCursor(c string) (uint32, error) { return uint32(pos), nil } -func (f *fakeDatastore) ListTickets(_ context.Context, rc datastores.TicketReferenceCache, flt *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { +func (f *fakeDatastore) ListTickets(_ context.Context, rc datastores.TicketReferenceCache, flt *datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { f.lastRC = rc if f.listTickets != nil { return f.listTickets(flt) @@ -222,7 +222,7 @@ func (f *fakeDatastore) ListTickets(_ context.Context, rc datastores.TicketRefer } } - var rows []*proto.Ticket + var rows []*types.Ticket for _, t := range seedTickets() { if hasCursor && !(t.LastModifiedDate < afterTs || (t.LastModifiedDate == afterTs && t.TicketId < afterId)) { continue @@ -243,12 +243,12 @@ func (f *fakeDatastore) ListTickets(_ context.Context, rc datastores.TicketRefer next = encodeTicketCursor(last.LastModifiedDate, last.TicketId) } if rows == nil { - rows = []*proto.Ticket{} + rows = []*types.Ticket{} } return rows, next, hasMore, nil } -func matchTicket(t *proto.Ticket, f *datastores.ListTicketsFilter) bool { +func matchTicket(t *types.Ticket, f *datastores.ListTicketsFilter) bool { if len(f.CategoryIDs) > 0 { match := containsU32(f.CategoryIDs, t.CategoryId) if !match && !f.ExcludeSubcategories { @@ -302,7 +302,7 @@ func containsStr(haystack []string, needle string) bool { return false } -func (f *fakeDatastore) GetTicket(_ context.Context, rc datastores.TicketReferenceCache, ticketID uint32, _ string) (*proto.Ticket, error) { +func (f *fakeDatastore) GetTicket(_ context.Context, rc datastores.TicketReferenceCache, ticketID uint32, _ string) (*types.Ticket, error) { f.lastRC = rc if f.getTicket != nil { return f.getTicket(ticketID) @@ -315,7 +315,7 @@ func (f *fakeDatastore) GetTicket(_ context.Context, rc datastores.TicketReferen return nil, gorm.ErrRecordNotFound } -func (f *fakeDatastore) GetTicketByRef(_ context.Context, rc datastores.TicketReferenceCache, ref string, _ string) (*proto.Ticket, error) { +func (f *fakeDatastore) GetTicketByRef(_ context.Context, rc datastores.TicketReferenceCache, ref string, _ string) (*types.Ticket, error) { f.lastRC = rc if f.getTicketByRef != nil { return f.getTicketByRef(ref) @@ -328,12 +328,12 @@ func (f *fakeDatastore) GetTicketByRef(_ context.Context, rc datastores.TicketRe return nil, gorm.ErrRecordNotFound } -func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, ticketID uint32, n int, includeHidden bool) ([]*proto.Message, uint32, error) { +func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, ticketID uint32, n int, includeHidden bool) ([]*types.Message, uint32, error) { if f.getTicketFirstMessages != nil { return f.getTicketFirstMessages(ticketID, n, includeHidden) } if ticketID != 42 { - return []*proto.Message{}, 0, nil + return []*types.Message{}, 0, nil } msgs := seedMessages42() total := uint32(len(msgs)) @@ -343,7 +343,7 @@ func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, ticketID uint3 return msgs, total, nil } -func (f *fakeDatastore) ListTicketMessages(_ context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*proto.Message, string, bool, error) { +func (f *fakeDatastore) ListTicketMessages(_ context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*types.Message, string, bool, error) { if f.listTicketMessages != nil { return f.listTicketMessages(ticketID, afterCursor, perPage, includeHidden) } @@ -356,7 +356,7 @@ func (f *fakeDatastore) ListTicketMessages(_ context.Context, ticketID uint32, a } else if perPage > 100 { perPage = 100 } - var rows []*proto.Message + var rows []*types.Message if ticketID == 42 { for _, m := range seedMessages42() { if m.Position >= from { @@ -373,17 +373,17 @@ func (f *fakeDatastore) ListTicketMessages(_ context.Context, ticketID uint32, a next = encodeMessageCursor(rows[len(rows)-1].Position + 1) } if rows == nil { - rows = []*proto.Message{} + rows = []*types.Message{} } return rows, next, hasMore, nil } -func (f *fakeDatastore) ListCategories(_ context.Context, rc datastores.TicketReferenceCache) ([]*proto.Category, error) { +func (f *fakeDatastore) ListCategories(_ context.Context, rc datastores.TicketReferenceCache) ([]*types.Category, error) { f.lastRC = rc if f.listCategories != nil { return f.listCategories() } - return []*proto.Category{ + return []*types.Category{ {CategoryId: 1, Title: "Recruiting", Description: "Enlistment & recruiting questions", ParentCategoryId: 0, Depth: 0, DisplayOrder: 10, TicketCount: 120}, {CategoryId: 5, Title: "S1 Personnel", Description: "", ParentCategoryId: 1, Depth: 1, DisplayOrder: 20, TicketCount: 34}, {CategoryId: 7, Title: "S6 Technical Support", Description: "Teamspeak, forum & game-server help", ParentCategoryId: 0, Depth: 0, DisplayOrder: 30, TicketCount: 78}, diff --git a/rest/metrics_test.go b/rest/metrics_test.go index 0731257..da9bd46 100644 --- a/rest/metrics_test.go +++ b/rest/metrics_test.go @@ -12,8 +12,8 @@ import ( "testing" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/rest" + "github.com/7cav/api/types" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" "github.com/prometheus/common/model" @@ -278,7 +278,7 @@ func TestMetrics_ExoticMethodClampsToOther(t *testing.T) { // unchanged so the sentry recovery layer outside this one (#132) — and // net/http when sentry is disabled, as here — sees identical semantics. func TestMetrics_PanickingHandlerMetersAs500AndPanicPropagates(t *testing.T) { - h := rest.New(&fakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := rest.New(&fakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { panic("datastore exploded") }}, &stubReferenceCache{}) labels := map[string]string{ @@ -308,7 +308,7 @@ func TestMetrics_PanickingHandlerMetersAs500AndPanicPropagates(t *testing.T) { // surfacing through the writeError choke point) meters under the route it // failed on, with the caller's key id — per-key error attribution. func TestMetrics_HandlerError500MetersUnderRouteAndKey(t *testing.T) { - h := rest.New(&fakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := rest.New(&fakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { return nil, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) labels := map[string]string{ diff --git a/rest/positions.go b/rest/positions.go index 0fd36eb..c6dcf50 100644 --- a/rest/positions.go +++ b/rest/positions.go @@ -69,6 +69,10 @@ func searchByPosition(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } + if roster.Profiles == nil { + // Frozen empty-result form: {"profiles":{}}, never null (#137). + roster.Profiles = map[uint64]*types.LiteProfile{} + } writeJSON(w, r, roster) }) } diff --git a/rest/positions_test.go b/rest/positions_test.go index b15f816..4b7961e 100644 --- a/rest/positions_test.go +++ b/rest/positions_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/7cav/api/proto" "github.com/7cav/api/rest" + "github.com/7cav/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -32,9 +32,9 @@ func positionsGet(t *testing.T, h http.Handler, path, key string) *httptest.Resp // strings verbatim from the old handlers, servers/grpc). func TestNewStack_PositionAndAwolOutagesAreInternalJSON(t *testing.T) { h := rest.New(&fakeDatastore{ - findAllPositionGroups: func() ([]*proto.PositionGroup, error) { return nil, io.ErrUnexpectedEOF }, - findProfilesByPosition: func(string) (*proto.LiteRoster, error) { return nil, io.ErrUnexpectedEOF }, - findAwol: func() ([]*proto.Awol, error) { return nil, io.ErrUnexpectedEOF }, + findAllPositionGroups: func() ([]*types.PositionGroup, error) { return nil, io.ErrUnexpectedEOF }, + findProfilesByPosition: func(string) (*types.LiteRoster, error) { return nil, io.ErrUnexpectedEOF }, + findAwol: func() ([]*types.Awol, error) { return nil, io.ErrUnexpectedEOF }, }, &stubReferenceCache{}) cases := []struct { @@ -59,7 +59,7 @@ func TestNewStack_PositionAndAwolOutagesAreInternalJSON(t *testing.T) { // allocation discipline (empty collections are [], never null) the goldens // can only witness on populated routes. func TestNewStack_EmptyPositionGroupsIsEmptyArray(t *testing.T) { - h := rest.New(&fakeDatastore{findAllPositionGroups: func() ([]*proto.PositionGroup, error) { + h := rest.New(&fakeDatastore{findAllPositionGroups: func() ([]*types.PositionGroup, error) { return nil, nil }}, &stubReferenceCache{}) @@ -73,8 +73,8 @@ func TestNewStack_EmptyPositionGroupsIsEmptyArray(t *testing.T) { // emits {"profiles":{}} — the mapper allocates, so the frozen empty-result // form (#137) cannot regress to null through a lazy datastore. func TestNewStack_SearchNilProfilesMapIsEmptyObject(t *testing.T) { - h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*proto.LiteRoster, error) { - return &proto.LiteRoster{}, nil // Profiles map nil, not allocated + h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*types.LiteRoster, error) { + return &types.LiteRoster{}, nil // Profiles map nil, not allocated }}, &stubReferenceCache{}) rr := positionsGet(t, h, "/api/v1/milpacs/position/search/Rifleman", "cav7_readkey") @@ -87,7 +87,7 @@ func TestNewStack_SearchNilProfilesMapIsEmptyObject(t *testing.T) { // (datastores.Mysql always allocates): it must surface as the frozen // Internal shape, not a panic or a fabricated empty 200. func TestNewStack_SearchNilRosterWithNilErrorIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*proto.LiteRoster, error) { + h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*types.LiteRoster, error) { return nil, nil }}, &stubReferenceCache{}) @@ -103,12 +103,12 @@ func TestNewStack_SearchNilRosterWithNilErrorIsInternalJSON(t *testing.T) { // recording-seed shape (seedDoeLite, contract/fake_datastore_test.go) minus // User/Rank, so every nil-guard branch in liteRosterFromProto runs unset. func TestNewStack_SearchSparseLiteProfilePreservesNils(t *testing.T) { - h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*proto.LiteRoster, error) { - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{2: { + h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*types.LiteRoster, error) { + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{2: { RealName: "John Doe", UniformUrl: "https://7cav.us/data/roster_uniforms/0/2.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, - Secondaries: []*proto.Position{}, + Roster: types.RosterTypeCombat, + Secondaries: []*types.Position{}, JoinDate: "2026-01-15", ConsoleGamertag: "CavGamer77", }}}, nil @@ -146,9 +146,9 @@ func TestNewStack_SearchSparseLiteProfilePreservesNils(t *testing.T) { // decode. func TestNewStack_SearchQueryDecodesOncePreservingSlashes(t *testing.T) { var got string - h := rest.New(&fakeDatastore{findProfilesByPosition: func(q string) (*proto.LiteRoster, error) { + h := rest.New(&fakeDatastore{findProfilesByPosition: func(q string) (*types.LiteRoster, error) { got = q - return &proto.LiteRoster{Profiles: map[uint64]*proto.LiteProfile{}}, nil + return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{}}, nil }}, &stubReferenceCache{}) cases := []struct { diff --git a/rest/rest_test.go b/rest/rest_test.go index 45e53cc..2f2b7b0 100644 --- a/rest/rest_test.go +++ b/rest/rest_test.go @@ -11,9 +11,9 @@ import ( "github.com/7cav/api/contract" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" "github.com/7cav/api/rest" + "github.com/7cav/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -37,32 +37,32 @@ func init() { // a loud failure if a test reaches further than the routes it mounts. type fakeDatastore struct { datastores.Datastore - findAllRanks func() ([]*proto.RankExpanded, error) + findAllRanks func() ([]*types.RankExpanded, error) validateApiKey func(rawKey string) (*datastores.ApiKeyResult, error) - findProfilesById func(userIds ...uint64) ([]*proto.Profile, error) - findProfilesByUsername func(username string) ([]*proto.Profile, error) - findProfileByDiscordID func(discordId string) (*proto.Profile, error) - findProfileByGamertag func(gamertag string) (*proto.Profile, error) + findProfilesById func(userIds ...uint64) ([]*types.Profile, error) + findProfilesByUsername func(username string) ([]*types.Profile, error) + findProfileByDiscordID func(discordId string) (*types.Profile, error) + findProfileByGamertag func(gamertag string) (*types.Profile, error) // Positions/AWOL overrides (#128; seeded defaults live in // fake_positions_test.go); a test sets one to inject an outage. - findAllPositionGroups func() ([]*proto.PositionGroup, error) - findProfilesByPosition func(positionQuery string) (*proto.LiteRoster, error) - findAwol func() ([]*proto.Awol, error) + findAllPositionGroups func() ([]*types.PositionGroup, error) + findProfilesByPosition func(positionQuery string) (*types.LiteRoster, error) + findAwol func() ([]*types.Awol, error) // Tickets overrides (seeded defaults live in fake_tickets_test.go); a // test sets one to inject an outage or observe the bound filter. - listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) - getTicket func(ticketID uint32) (*proto.Ticket, error) - getTicketByRef func(ref string) (*proto.Ticket, error) - getTicketFirstMessages func(ticketID uint32, n int, includeHidden bool) ([]*proto.Message, uint32, error) - listTicketMessages func(ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*proto.Message, string, bool, error) - listCategories func() ([]*proto.Category, error) + listTickets func(*datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) + getTicket func(ticketID uint32) (*types.Ticket, error) + getTicketByRef func(ref string) (*types.Ticket, error) + getTicketFirstMessages func(ticketID uint32, n int, includeHidden bool) ([]*types.Message, uint32, error) + listTicketMessages func(ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*types.Message, string, bool, error) + listCategories func() ([]*types.Category, error) // Roster overrides (seeded defaults live in fake_rosters_test.go). - findRosterByType func(proto.RosterType) (*proto.Roster, error) - findLiteRosterByType func(proto.RosterType) (*proto.LiteRoster, error) - findS1UniformsRosterByType func(proto.RosterType) (*proto.S1UniformsRoster, error) + findRosterByType func(types.RosterType) (*types.Roster, error) + findLiteRosterByType func(types.RosterType) (*types.LiteRoster, error) + findS1UniformsRosterByType func(types.RosterType) (*types.S1UniformsRoster, error) // lastRC records the TicketReferenceCache the handlers handed the most // recent rc-consuming datastore call — the identity pin asserts it IS the @@ -108,11 +108,11 @@ func (f *fakeDatastore) ValidateApiKey(rawKey string) (*datastores.ApiKeyResult, } } -func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { +func (f *fakeDatastore) FindAllRanks() ([]*types.RankExpanded, error) { if f.findAllRanks != nil { return f.findAllRanks() } - return []*proto.RankExpanded{ + return []*types.RankExpanded{ {RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618", RankId: 4, RankDisplayOrder: 4}, {RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg", RankId: 22, RankDisplayOrder: 22}, }, nil @@ -124,13 +124,11 @@ func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { var errOutage = errors.New("simulated datastore outage") // seedJarvis mirrors the recording seed's rich profile: every collection -// populated, relation 1 ↔ user 3. KeycloakId is deliberately set — the -// corpus transform stripped it from the goldens, so the mapper dropping it -// is observable in the replay. -func seedJarvis() *proto.Profile { - return &proto.Profile{ - User: &proto.User{UserId: 3, Username: "Jarvis.A"}, - Rank: &proto.Rank{ +// populated, relation 1 ↔ user 3. +func seedJarvis() *types.Profile { + return &types.Profile{ + User: &types.User{UserId: 3, Username: "Jarvis.A"}, + Rank: &types.Rank{ RankShort: "MG", RankFull: "Major General", RankImageUrl: "https://7cav.us/data/roster_ranks/0/4.jpg?1741364618", @@ -138,26 +136,26 @@ func seedJarvis() *proto.Profile { }, RealName: "Adam Jarvis", UniformUrl: "https://7cav.us/data/roster_uniforms/0/1.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, - Primary: &proto.Position{PositionTitle: "Regimental Technical Aide", PositionId: 773}, - Secondaries: []*proto.Position{ + Roster: types.RosterTypeCombat, + Primary: &types.Position{PositionTitle: "Regimental Technical Aide", PositionId: 773}, + Secondaries: []*types.Position{ {PositionTitle: "S6 Web Developer", PositionId: 812}, }, - Records: []*proto.Record{ + Records: []*types.Record{ { RecordDetails: "Promoted to Major General (O-8)", - RecordType: proto.RecordType_RECORD_TYPE_PROMOTION, + RecordType: types.RecordTypePromotion, RecordDate: "2020-10-17", RecordUid: 46, }, { RecordDetails: "Completed 18th Combat Mission (Operation Pride of Charlie, Fall 2020)", - RecordType: proto.RecordType_RECORD_TYPE_OPERATION, + RecordType: types.RecordTypeOperation, RecordDate: "2020-09-27", RecordUid: 13863, }, }, - Awards: []*proto.Award{ + Awards: []*types.Award{ { AwardDetails: "For technical excellence & dedication ", AwardName: "Commendation Medal", @@ -168,7 +166,6 @@ func seedJarvis() *proto.Profile { }, JoinDate: "2014-02-08", PromotionDate: "2020-10-17", - KeycloakId: "3f8e2a10-dead-beef-cafe-0123456789ab", DiscordId: "112233445566778899", LastForumPostDate: "2026-05-30", Mos: "11B", @@ -179,17 +176,17 @@ func seedJarvis() *proto.Profile { // seedDoe mirrors the recording seed's sparse profile: unset nested messages // nil (null on the wire), collections empty ([]), strings "". Relation 2 ↔ // user 8. -func seedDoe() *proto.Profile { - return &proto.Profile{ - User: &proto.User{UserId: 8, Username: "John.Doe"}, - Rank: &proto.Rank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg", RankId: 22}, +func seedDoe() *types.Profile { + return &types.Profile{ + User: &types.User{UserId: 8, Username: "John.Doe"}, + Rank: &types.Rank{RankShort: "PVT", RankFull: "Private", RankImageUrl: "https://7cav.us/data/roster_ranks/0/22.jpg", RankId: 22}, RealName: "John Doe", UniformUrl: "https://7cav.us/data/roster_uniforms/0/2.jpg", - Roster: proto.RosterType_ROSTER_TYPE_COMBAT, + Roster: types.RosterTypeCombat, Primary: nil, // → "primary": null - Secondaries: []*proto.Position{}, - Records: []*proto.Record{}, - Awards: []*proto.Award{}, + Secondaries: []*types.Position{}, + Records: []*types.Record{}, + Awards: []*types.Award{}, JoinDate: "2026-01-15", ConsoleGamertag: "CavGamer77", } @@ -198,15 +195,15 @@ func seedDoe() *proto.Profile { // FindProfilesById keys on the MILPAC RELATION ID, mirroring the recording // seed (and datastores.Mysql: gorm First keys on the milpacs.Profile primary // key = relation_id). 777 is the injected-outage id for the 500 golden. -func (f *fakeDatastore) FindProfilesById(userIds ...uint64) ([]*proto.Profile, error) { +func (f *fakeDatastore) FindProfilesById(userIds ...uint64) ([]*types.Profile, error) { if f.findProfilesById != nil { return f.findProfilesById(userIds...) } switch userIds[0] { case 1: - return []*proto.Profile{seedJarvis()}, nil + return []*types.Profile{seedJarvis()}, nil case 2: - return []*proto.Profile{seedDoe()}, nil + return []*types.Profile{seedDoe()}, nil case 777: return nil, errOutage default: @@ -214,21 +211,21 @@ func (f *fakeDatastore) FindProfilesById(userIds ...uint64) ([]*proto.Profile, e } } -func (f *fakeDatastore) FindProfilesByUsername(username string) ([]*proto.Profile, error) { +func (f *fakeDatastore) FindProfilesByUsername(username string) ([]*types.Profile, error) { if f.findProfilesByUsername != nil { return f.findProfilesByUsername(username) } switch username { case "Jarvis.A": - return []*proto.Profile{seedJarvis()}, nil + return []*types.Profile{seedJarvis()}, nil case "John.Doe": - return []*proto.Profile{seedDoe()}, nil + return []*types.Profile{seedDoe()}, nil default: return nil, gorm.ErrRecordNotFound } } -func (f *fakeDatastore) FindProfileByDiscordID(discordId string) (*proto.Profile, error) { +func (f *fakeDatastore) FindProfileByDiscordID(discordId string) (*types.Profile, error) { if f.findProfileByDiscordID != nil { return f.findProfileByDiscordID(discordId) } @@ -238,7 +235,7 @@ func (f *fakeDatastore) FindProfileByDiscordID(discordId string) (*proto.Profile return nil, gorm.ErrRecordNotFound } -func (f *fakeDatastore) FindProfileByGamertag(gamertag string) (*proto.Profile, error) { +func (f *fakeDatastore) FindProfileByGamertag(gamertag string) (*types.Profile, error) { if f.findProfileByGamertag != nil { return f.findProfileByGamertag(gamertag) } @@ -485,7 +482,7 @@ func TestNewStack_401IsNeverGzipped(t *testing.T) { // The datastore failing must surface as the frozen Internal error shape // through the choke point (message text mirrors the old handler verbatim). func TestNewStack_RanksDatastoreOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := rest.New(&fakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { return nil, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -504,11 +501,11 @@ func TestNewStack_RanksDatastoreOutageIsInternalJSON(t *testing.T) { // variant is golden-pinned via profile_by_id_internal_error; the corpus has // no outage cases for the other three, so these pin them). func TestNewStack_ProfileLookupOutagesAreInternalJSON(t *testing.T) { - outage := func() ([]*proto.Profile, error) { return nil, io.ErrUnexpectedEOF } + outage := func() ([]*types.Profile, error) { return nil, io.ErrUnexpectedEOF } h := rest.New(&fakeDatastore{ - findProfilesByUsername: func(string) ([]*proto.Profile, error) { return outage() }, - findProfileByDiscordID: func(string) (*proto.Profile, error) { return nil, io.ErrUnexpectedEOF }, - findProfileByGamertag: func(string) (*proto.Profile, error) { return nil, io.ErrUnexpectedEOF }, + findProfilesByUsername: func(string) ([]*types.Profile, error) { return outage() }, + findProfileByDiscordID: func(string) (*types.Profile, error) { return nil, io.ErrUnexpectedEOF }, + findProfileByGamertag: func(string) (*types.Profile, error) { return nil, io.ErrUnexpectedEOF }, }, &stubReferenceCache{}) cases := []struct { @@ -539,8 +536,8 @@ func TestNewStack_ProfileLookupOutagesAreInternalJSON(t *testing.T) { // fabricated sparse 200. func TestNewStack_EmptyProfileSliceWithNilErrorIsInternalJSON(t *testing.T) { h := rest.New(&fakeDatastore{ - findProfilesById: func(...uint64) ([]*proto.Profile, error) { return []*proto.Profile{}, nil }, - findProfilesByUsername: func(string) ([]*proto.Profile, error) { return nil, nil }, + findProfilesById: func(...uint64) ([]*types.Profile, error) { return []*types.Profile{}, nil }, + findProfilesByUsername: func(string) ([]*types.Profile, error) { return nil, nil }, }, &stubReferenceCache{}) for _, path := range []string{ @@ -561,10 +558,10 @@ func TestNewStack_EmptyProfileSliceWithNilErrorIsInternalJSON(t *testing.T) { func TestNewStack_NilProfileWithNilErrorIsInternalJSON(t *testing.T) { h := rest.New(&fakeDatastore{ - findProfileByDiscordID: func(string) (*proto.Profile, error) { return nil, nil }, - findProfileByGamertag: func(string) (*proto.Profile, error) { return nil, nil }, + findProfileByDiscordID: func(string) (*types.Profile, error) { return nil, nil }, + findProfileByGamertag: func(string) (*types.Profile, error) { return nil, nil }, // A non-empty slice carrying a nil element hits the same guard. - findProfilesById: func(...uint64) ([]*proto.Profile, error) { return []*proto.Profile{nil}, nil }, + findProfilesById: func(...uint64) ([]*types.Profile, error) { return []*types.Profile{nil}, nil }, }, &stubReferenceCache{}) for _, path := range []string{ @@ -1252,7 +1249,7 @@ func TestNewStack_ReferenceCacheReachesDatastoreByIdentity(t *testing.T) { // discipline (empty collections are [], never null) the goldens can only // witness on populated routes. func TestNewStack_EmptyRanksIsEmptyArray(t *testing.T) { - h := rest.New(&fakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := rest.New(&fakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { return nil, nil }}, &stubReferenceCache{}) diff --git a/rest/rosters.go b/rest/rosters.go index d3df427..83c862c 100644 --- a/rest/rosters.go +++ b/rest/rosters.go @@ -71,6 +71,12 @@ func getRoster(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } + if roster.Profiles == nil { + // Frozen empty-result form: {"profiles":{}}, never null (#137). The + // real datastore always allocates; this guards a nil map slipping + // through (the old stack's EmitUnpopulated marshaler emitted {}). + roster.Profiles = map[uint64]*types.Profile{} + } writeJSON(w, r, roster) }) } @@ -93,6 +99,9 @@ func getLiteRoster(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } + if roster.Profiles == nil { + roster.Profiles = map[uint64]*types.LiteProfile{} + } writeJSON(w, r, roster) }) } @@ -115,6 +124,9 @@ func getS1UniformsRoster(ds datastores.Datastore) http.Handler { writeError(w, r, codeInternal, "datastore returned no roster") return } + if roster.Profiles == nil { + roster.Profiles = map[uint64]*types.S1UniformsProfile{} + } writeJSON(w, r, roster) }) } diff --git a/rest/rosters_test.go b/rest/rosters_test.go index 5c15b4a..0c73045 100644 --- a/rest/rosters_test.go +++ b/rest/rosters_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/7cav/api/contract" - "github.com/7cav/api/proto" "github.com/7cav/api/rest" + "github.com/7cav/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -164,8 +164,8 @@ func TestNewStack_RosterRoutes_MalformedQuerySyntaxIgnored(t *testing.T) { // cases for these two, so these pin them. The enum NAME interpolates via %s. func TestNewStack_LiteAndS1OutagesAreInternalJSON(t *testing.T) { h := rest.New(&fakeDatastore{ - findLiteRosterByType: func(proto.RosterType) (*proto.LiteRoster, error) { return nil, io.ErrUnexpectedEOF }, - findS1UniformsRosterByType: func(proto.RosterType) (*proto.S1UniformsRoster, error) { return nil, io.ErrUnexpectedEOF }, + findLiteRosterByType: func(types.RosterType) (*types.LiteRoster, error) { return nil, io.ErrUnexpectedEOF }, + findS1UniformsRosterByType: func(types.RosterType) (*types.S1UniformsRoster, error) { return nil, io.ErrUnexpectedEOF }, }, &stubReferenceCache{}) cases := []struct { @@ -189,9 +189,9 @@ func TestNewStack_LiteAndS1OutagesAreInternalJSON(t *testing.T) { // or a fabricated empty 200. func TestNewStack_NilRosterWithNilErrorIsInternalJSON(t *testing.T) { h := rest.New(&fakeDatastore{ - findRosterByType: func(proto.RosterType) (*proto.Roster, error) { return nil, nil }, - findLiteRosterByType: func(proto.RosterType) (*proto.LiteRoster, error) { return nil, nil }, - findS1UniformsRosterByType: func(proto.RosterType) (*proto.S1UniformsRoster, error) { return nil, nil }, + findRosterByType: func(types.RosterType) (*types.Roster, error) { return nil, nil }, + findLiteRosterByType: func(types.RosterType) (*types.LiteRoster, error) { return nil, nil }, + findS1UniformsRosterByType: func(types.RosterType) (*types.S1UniformsRoster, error) { return nil, nil }, }, &stubReferenceCache{}) for _, path := range rosterRoutePaths { @@ -214,13 +214,13 @@ func TestNewStack_NilProfilesMapServesEmptyObject(t *testing.T) { fake *fakeDatastore }{ {"/api/v1/roster/ROSTER_TYPE_COMBAT", &fakeDatastore{ - findRosterByType: func(proto.RosterType) (*proto.Roster, error) { return &proto.Roster{}, nil }, + findRosterByType: func(types.RosterType) (*types.Roster, error) { return &types.Roster{}, nil }, }}, {"/api/v1/roster/ROSTER_TYPE_COMBAT/lite", &fakeDatastore{ - findLiteRosterByType: func(proto.RosterType) (*proto.LiteRoster, error) { return &proto.LiteRoster{}, nil }, + findLiteRosterByType: func(types.RosterType) (*types.LiteRoster, error) { return &types.LiteRoster{}, nil }, }}, {"/api/v1/s1/uniforms/ROSTER_TYPE_COMBAT", &fakeDatastore{ - findS1UniformsRosterByType: func(proto.RosterType) (*proto.S1UniformsRoster, error) { return &proto.S1UniformsRoster{}, nil }, + findS1UniformsRosterByType: func(types.RosterType) (*types.S1UniformsRoster, error) { return &types.S1UniformsRoster{}, nil }, }}, } for _, tc := range cases { @@ -259,8 +259,8 @@ func TestNewStack_LargeRosterComparesCleanAtFullSize(t *testing.T) { const members = 4000 h := rest.New(&fakeDatastore{ - findRosterByType: func(proto.RosterType) (*proto.Roster, error) { - profiles := make(map[uint64]*proto.Profile, members) + findRosterByType: func(types.RosterType) (*types.Roster, error) { + profiles := make(map[uint64]*types.Profile, members) for i := uint64(1); i <= members; i++ { if i%2 == 1 { profiles[i] = seedJarvis() @@ -268,7 +268,7 @@ func TestNewStack_LargeRosterComparesCleanAtFullSize(t *testing.T) { profiles[i] = seedDoe() } } - return &proto.Roster{Profiles: profiles}, nil + return &types.Roster{Profiles: profiles}, nil }, }, &stubReferenceCache{}) diff --git a/rest/sentry_test.go b/rest/sentry_test.go index 7a37341..2794fcb 100644 --- a/rest/sentry_test.go +++ b/rest/sentry_test.go @@ -21,8 +21,8 @@ import ( "time" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" + "github.com/7cav/api/types" "github.com/getsentry/sentry-go" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/spf13/viper" @@ -36,8 +36,8 @@ import ( // "read" — the id (never the token) is what events must carry. type sentryFakeDatastore struct { datastores.Datastore - findAllRanks func() ([]*proto.RankExpanded, error) - findProfilesById func(...uint64) ([]*proto.Profile, error) + findAllRanks func() ([]*types.RankExpanded, error) + findProfilesById func(...uint64) ([]*types.Profile, error) validateApiKey func(string) (*datastores.ApiKeyResult, error) } @@ -51,11 +51,11 @@ func (f *sentryFakeDatastore) ValidateApiKey(rawKey string) (*datastores.ApiKeyR return nil, nil } -func (f *sentryFakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { +func (f *sentryFakeDatastore) FindAllRanks() ([]*types.RankExpanded, error) { return f.findAllRanks() } -func (f *sentryFakeDatastore) FindProfilesById(ids ...uint64) ([]*proto.Profile, error) { +func (f *sentryFakeDatastore) FindProfilesById(ids ...uint64) ([]*types.Profile, error) { return f.findProfilesById(ids...) } @@ -190,7 +190,7 @@ func TestSentryMiddleware_MountsCommitWriterWhenEnabled(t *testing.T) { func TestSentry_PanicCompletesAs500AndReportsTaggedEvent(t *testing.T) { tr := enableSentry(t) captureErrorLog(t) // panic + 5xx logging stays server-side, not in test output - h := New(&sentryFakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := New(&sentryFakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { panic("ranks exploded") }}, &sentryStubCache{}) @@ -228,7 +228,7 @@ func TestSentry_PanicCompletesAs500AndReportsTaggedEvent(t *testing.T) { func TestSentry_Handler500ThroughChokePointReportsEvent(t *testing.T) { tr := enableSentry(t) captureErrorLog(t) - h := New(&sentryFakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := New(&sentryFakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { return nil, errOutageSentry }}, &sentryStubCache{}) @@ -319,10 +319,10 @@ func TestSentry_BearerMaterialAbsentFromEventPayloads(t *testing.T) { tr := enableSentry(t) captureErrorLog(t) - panicStack := New(&sentryFakeDatastore{validateApiKey: acceptAny, findAllRanks: func() ([]*proto.RankExpanded, error) { + panicStack := New(&sentryFakeDatastore{validateApiKey: acceptAny, findAllRanks: func() ([]*types.RankExpanded, error) { panic("boom") }}, &sentryStubCache{}) - errorStack := New(&sentryFakeDatastore{validateApiKey: acceptAny, findAllRanks: func() ([]*proto.RankExpanded, error) { + errorStack := New(&sentryFakeDatastore{validateApiKey: acceptAny, findAllRanks: func() ([]*types.RankExpanded, error) { return nil, errOutageSentry }}, &sentryStubCache{}) outageStack := New(&sentryFakeDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) { @@ -390,7 +390,7 @@ func TestSentry_NoDSNIsCompletePassThrough(t *testing.T) { require.Nil(t, sentry.CurrentHub().Client(), "precondition: no client bound") captureErrorLog(t) - h := New(&sentryFakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := New(&sentryFakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { panic("ranks exploded") }}, &sentryStubCache{}) @@ -406,7 +406,7 @@ func TestSentry_NoDSNIsCompletePassThrough(t *testing.T) { "without a DSN the panic must propagate unchanged — no recovery, no rewriting of crash semantics") assert.Zero(t, rr.Body.Len(), "no recovery layer means nothing is written for the panicked request") - h500 := New(&sentryFakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h500 := New(&sentryFakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { return nil, errOutageSentry }}, &sentryStubCache{}) rr = doRanks(h500) @@ -457,7 +457,7 @@ func TestSentry_PanicAfterCommittedResponseReportsAndRepanics(t *testing.T) { func TestSentry_GzippedPanicReportsAndRepanics(t *testing.T) { tr := enableSentry(t) captureErrorLog(t) - h := New(&sentryFakeDatastore{findAllRanks: func() ([]*proto.RankExpanded, error) { + h := New(&sentryFakeDatastore{findAllRanks: func() ([]*types.RankExpanded, error) { panic("ranks exploded") }}, &sentryStubCache{}) @@ -506,11 +506,11 @@ func TestSentry_ConcurrentRequestsKeepIsolatedTags(t *testing.T) { } return nil, nil }, - findAllRanks: func() ([]*proto.RankExpanded, error) { + findAllRanks: func() ([]*types.RankExpanded, error) { gate() return nil, errOutageSentry }, - findProfilesById: func(...uint64) ([]*proto.Profile, error) { + findProfilesById: func(...uint64) ([]*types.Profile, error) { gate() return nil, errOutageSentry }, diff --git a/rest/spec_test.go b/rest/spec_test.go index 62c8750..0df7080 100644 --- a/rest/spec_test.go +++ b/rest/spec_test.go @@ -28,8 +28,8 @@ import ( "github.com/7cav/api/contract" "github.com/7cav/api/internal/spectest" - "github.com/7cav/api/proto" "github.com/7cav/api/rest" + "github.com/7cav/api/types" "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi-validator/paths" "github.com/pb33f/libopenapi-validator/responses" @@ -340,14 +340,14 @@ func TestNewStack_SpecValidation(t *testing.T) { status: http.StatusInternalServerError, path: "/api/v1/roster/ROSTER_TYPE_COMBAT/lite", ds: &fakeDatastore{ - findLiteRosterByType: func(proto.RosterType) (*proto.LiteRoster, error) { return nil, io.ErrUnexpectedEOF }, + findLiteRosterByType: func(types.RosterType) (*types.LiteRoster, error) { return nil, io.ErrUnexpectedEOF }, }, }, "s1_uniforms_500_outage": { status: http.StatusInternalServerError, path: "/api/v1/s1/uniforms/ROSTER_TYPE_COMBAT", ds: &fakeDatastore{ - findS1UniformsRosterByType: func(proto.RosterType) (*proto.S1UniformsRoster, error) { return nil, io.ErrUnexpectedEOF }, + findS1UniformsRosterByType: func(types.RosterType) (*types.S1UniformsRoster, error) { return nil, io.ErrUnexpectedEOF }, }, }, } diff --git a/rest/tickets_test.go b/rest/tickets_test.go index 3180a7a..f0b97b5 100644 --- a/rest/tickets_test.go +++ b/rest/tickets_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" "github.com/7cav/api/rest" + "github.com/7cav/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,7 +34,7 @@ func ticketsGet(t *testing.T, h http.Handler, path string) *httptest.ResponseRec // A datastore outage on the ticket fetch must surface as the frozen Internal // shape (message text mirrors the old handler verbatim: "fetch ticket: %v"). func TestNewStack_GetTicketDatastoreOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{getTicket: func(uint32) (*proto.Ticket, error) { + h := rest.New(&fakeDatastore{getTicket: func(uint32) (*types.Ticket, error) { return nil, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -47,7 +47,7 @@ func TestNewStack_GetTicketDatastoreOutageIsInternalJSON(t *testing.T) { // must be the frozen Internal shape, never a zeroed-garbage 200 via the // nil-safe proto getters. func TestNewStack_NilTicketWithNilErrorIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{getTicket: func(uint32) (*proto.Ticket, error) { + h := rest.New(&fakeDatastore{getTicket: func(uint32) (*types.Ticket, error) { return nil, nil }}, &stubReferenceCache{}) @@ -59,7 +59,7 @@ func TestNewStack_NilTicketWithNilErrorIsInternalJSON(t *testing.T) { // An outage on the first-messages fetch (after the ticket resolved) keeps its // own frozen message string: "fetch ticket messages: %v". func TestNewStack_GetTicketFirstMessagesOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{getTicketFirstMessages: func(uint32, int, bool) ([]*proto.Message, uint32, error) { + h := rest.New(&fakeDatastore{getTicketFirstMessages: func(uint32, int, bool) ([]*types.Message, uint32, error) { return nil, 0, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -70,7 +70,7 @@ func TestNewStack_GetTicketFirstMessagesOutageIsInternalJSON(t *testing.T) { // An outage on the categories list: "list ticket categories: %v". func TestNewStack_ListCategoriesOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{listCategories: func() ([]*proto.Category, error) { + h := rest.New(&fakeDatastore{listCategories: func() ([]*types.Category, error) { return nil, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -81,7 +81,7 @@ func TestNewStack_ListCategoriesOutageIsInternalJSON(t *testing.T) { // An outage on the tickets list: "list tickets: %v". func TestNewStack_ListTicketsOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{listTickets: func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + h := rest.New(&fakeDatastore{listTickets: func(*datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { return nil, "", false, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -93,7 +93,7 @@ func TestNewStack_ListTicketsOutageIsInternalJSON(t *testing.T) { // An empty tickets page must serialize with the collections allocated: // {"tickets":[],...} — never null. func TestNewStack_EmptyTicketsPageIsEmptyArray(t *testing.T) { - h := rest.New(&fakeDatastore{listTickets: func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + h := rest.New(&fakeDatastore{listTickets: func(*datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { return nil, "", false, nil }}, &stubReferenceCache{}) @@ -104,7 +104,7 @@ func TestNewStack_EmptyTicketsPageIsEmptyArray(t *testing.T) { // An outage on the messages list: "list ticket messages: %v". func TestNewStack_ListTicketMessagesOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{listTicketMessages: func(uint32, string, uint32, bool) ([]*proto.Message, string, bool, error) { + h := rest.New(&fakeDatastore{listTicketMessages: func(uint32, string, uint32, bool) ([]*types.Message, string, bool, error) { return nil, "", false, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -363,9 +363,9 @@ func TestNewStack_ScopeGatePrecedesBindingErrors(t *testing.T) { // nowhere else). func TestNewStack_ListTicketsBindsAllElevenFilterFields(t *testing.T) { var got *datastores.ListTicketsFilter - h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { got = f - return []*proto.Ticket{}, "", false, nil + return []*types.Ticket{}, "", false, nil }}, &stubReferenceCache{}) query := strings.Join([]string{ @@ -412,9 +412,9 @@ func TestNewStack_ListTicketsBindsAllElevenFilterFields(t *testing.T) { // binder (rest/query.go bracketGroups); converged with #126. func TestNewStack_BracketKeyFoldsIntoRepeatedField(t *testing.T) { var got *datastores.ListTicketsFilter - h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { got = f - return []*proto.Ticket{}, "", false, nil + return []*types.Ticket{}, "", false, nil }}, &stubReferenceCache{}) rr := ticketsGet(t, h, "/api/v1/tickets?status_id[0]=5") @@ -457,9 +457,9 @@ func TestNewStack_BracketKeyOnScalarIsTooManyValues400(t *testing.T) { // to "status_id". func TestNewStack_NonMatchingBracketKeysAreIgnored(t *testing.T) { var got *datastores.ListTicketsFilter - h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { got = f - return []*proto.Ticket{}, "", false, nil + return []*types.Ticket{}, "", false, nil }}, &stubReferenceCache{}) rr := ticketsGet(t, h, "/api/v1/tickets") @@ -482,9 +482,9 @@ func TestNewStack_NonMatchingBracketKeysAreIgnored(t *testing.T) { // key) is the deterministic RULING, same tier as camel-wins. func TestNewStack_BracketKeySpellingsAndOrderingRuling(t *testing.T) { var got *datastores.ListTicketsFilter - h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + h := rest.New(&fakeDatastore{listTickets: func(f *datastores.ListTicketsFilter) ([]*types.Ticket, string, bool, error) { got = f - return []*proto.Ticket{}, "", false, nil + return []*types.Ticket{}, "", false, nil }}, &stubReferenceCache{}) rr := ticketsGet(t, h, "/api/v1/tickets?statusId[0]=5") @@ -506,9 +506,9 @@ func TestNewStack_BracketKeySpellingsAndOrderingRuling(t *testing.T) { func TestNewStack_ListTicketMessagesIncludeHiddenReachesDatastore(t *testing.T) { for _, want := range []bool{true, false} { got, called := false, false - h := rest.New(&fakeDatastore{listTicketMessages: func(_ uint32, _ string, _ uint32, includeHidden bool) ([]*proto.Message, string, bool, error) { + h := rest.New(&fakeDatastore{listTicketMessages: func(_ uint32, _ string, _ uint32, includeHidden bool) ([]*types.Message, string, bool, error) { got, called = includeHidden, true - return []*proto.Message{}, "", false, nil + return []*types.Message{}, "", false, nil }}, &stubReferenceCache{}) path := "/api/v1/tickets/42/messages" @@ -525,7 +525,7 @@ func TestNewStack_ListTicketMessagesIncludeHiddenReachesDatastore(t *testing.T) // A by-ref datastore outage mirrors the by-id twin: same frozen "fetch // ticket: %v" Internal shape (both old handlers shared the string). func TestNewStack_GetTicketByRefDatastoreOutageIsInternalJSON(t *testing.T) { - h := rest.New(&fakeDatastore{getTicketByRef: func(string) (*proto.Ticket, error) { + h := rest.New(&fakeDatastore{getTicketByRef: func(string) (*types.Ticket, error) { return nil, io.ErrUnexpectedEOF }}, &stubReferenceCache{}) @@ -542,9 +542,9 @@ func TestNewStack_GetTicketByRefDatastoreOutageIsInternalJSON(t *testing.T) { func TestNewStack_FirstMessagesCountAndVisibilityFrozen(t *testing.T) { for _, path := range []string{"/api/v1/tickets/42", "/api/v1/tickets/ref/MF1UI9HE"} { gotN, gotHidden, called := 0, true, false - h := rest.New(&fakeDatastore{getTicketFirstMessages: func(_ uint32, n int, includeHidden bool) ([]*proto.Message, uint32, error) { + h := rest.New(&fakeDatastore{getTicketFirstMessages: func(_ uint32, n int, includeHidden bool) ([]*types.Message, uint32, error) { gotN, gotHidden, called = n, includeHidden, true - return []*proto.Message{}, 0, nil + return []*types.Message{}, 0, nil }}, &stubReferenceCache{}) rr := ticketsGet(t, h, path) @@ -558,7 +558,7 @@ func TestNewStack_FirstMessagesCountAndVisibilityFrozen(t *testing.T) { // An empty category tree must serialize as {"categories":[]} — allocation // discipline the goldens only witness populated. func TestNewStack_EmptyCategoriesIsEmptyArray(t *testing.T) { - h := rest.New(&fakeDatastore{listCategories: func() ([]*proto.Category, error) { + h := rest.New(&fakeDatastore{listCategories: func() ([]*types.Category, error) { return nil, nil }}, &stubReferenceCache{}) From 1279051a618de0d2183461fd1ef443960ddc407b Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:47:33 -0400 Subject: [PATCH 4/8] feat(rest): port sentry boot-lifecycle and add the docs handler Bring the three boot-lifecycle pieces from servers/sentry.go into the rest package so they survive the deletion of the old stack: sentryDialCheck (warn- only DSN reachability check), sentryStartupProbe (one canary flushed at boot), and FlushSentryOnShutdown (the SIGTERM flush handler). SetupSentry now drives the dial-check and probe, matching what servers.Start did. The probe-stall and dial-check tests come along, adapted to the rest seam (sentryTransport). Add DocsHandler: it serves the embedded Swagger UI and OpenAPI specs at the same URLs the old gateway used, and stamps the spec's info.version from the build-time version. That replaces the release workflow's proto-sed step; a dev build leaves the sentinel untouched. > *This was generated by AI* --- rest/docs.go | 68 +++++++++ rest/docs_test.go | 74 ++++++++++ rest/sentry.go | 13 +- rest/sentry_boot.go | 139 ++++++++++++++++++ rest/sentry_boot_test.go | 303 +++++++++++++++++++++++++++++++++++++++ rest/sentry_test.go | 10 ++ 6 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 rest/docs.go create mode 100644 rest/docs_test.go create mode 100644 rest/sentry_boot.go create mode 100644 rest/sentry_boot_test.go diff --git a/rest/docs.go b/rest/docs.go new file mode 100644 index 0000000..46a2843 --- /dev/null +++ b/rest/docs.go @@ -0,0 +1,68 @@ +package rest + +// Docs UI + OpenAPI spec serving for the single-listener stack (#134). The old +// grpc-gateway served the embedded Swagger UI for every path outside /api; the +// cutover folds that role into this package so one public listener serves both +// the API (rest.New) and the docs. +// +// info.version templating: the generated specs ship with the "dev" sentinel +// (proto/*.proto's `version: "dev";`). The release workflow used to sed the +// build tag into the proto before make generate; with serving in-process the +// build-time version is stamped onto the spec's info.version at request time +// instead, so a tagged binary reports its tag and a local `dev` build reports +// dev — no codegen step in the loop. + +import ( + "bytes" + "io/fs" + "mime" + "net/http" + "strings" + + "github.com/7cav/api/openapi" +) + +// specVersionSentinel is the info.version literal the generated swagger specs +// ship with (proto/*.proto: `version: "dev";`). DocsHandler rewrites it to the +// build-time version on the two *.swagger.json files. +const specVersionSentinel = `"version": "dev"` + +// DocsHandler serves the embedded Swagger UI and OpenAPI specs at the same URLs +// the old gateway used (everything outside /api). The two *.swagger.json specs +// have their info.version stamped from version; all other assets are served +// verbatim from the embedded filesystem. +// +// version is the build-time var (servers.version via -ldflags, "dev" locally), +// threaded in by the composition root. +func DocsHandler(version string) http.Handler { + // Match the old gateway: register the .svg MIME type so the favicons and + // any inline SVG assets serve with the right content type. + if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil { + Error.Println("failed to add MIME extension type for .svg: ", err) + } + sub, err := fs.Sub(openapi.Files, "assets") + if err != nil { + // Unreachable: the embed always contains assets/. Loud if it ever isn't. + Error.Fatalf("creating OpenAPI sub-filesystem: %v", err) + } + fileServer := http.FileServer(http.FS(sub)) + + replacement := []byte(`"version": "` + version + `"`) + stamp := version != "" && version != "dev" + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if stamp && strings.HasSuffix(r.URL.Path, ".swagger.json") { + name := strings.TrimPrefix(r.URL.Path, "/") + raw, err := fs.ReadFile(sub, name) + if err == nil { + body := bytes.Replace(raw, []byte(specVersionSentinel), replacement, 1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + return + } + // Fall through to the file server on any read miss (it produces the + // canonical 404), rather than masking a routing change. + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/rest/docs_test.go b/rest/docs_test.go new file mode 100644 index 0000000..ff34bf9 --- /dev/null +++ b/rest/docs_test.go @@ -0,0 +1,74 @@ +package rest_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/7cav/api/rest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func docsGet(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + return rr +} + +// A tagged build stamps its version onto the served spec's info.version, +// replacing the "dev" sentinel the generated spec ships with. This is the +// runtime substitute for the release workflow's old proto-sed step (#134). +func TestDocsHandler_StampsBuildVersionOntoSpec(t *testing.T) { + h := rest.DocsHandler("v2.5.0") + + for _, spec := range []string{"/milpacs.swagger.json", "/tickets.swagger.json"} { + t.Run(spec, func(t *testing.T) { + rr := docsGet(t, h, spec) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + var doc struct { + Info struct { + Version string `json:"version"` + } `json:"info"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &doc)) + assert.Equal(t, "v2.5.0", doc.Info.Version, + "the served spec must report the build-time version, not the dev sentinel") + assert.NotContains(t, rr.Body.String(), `"version": "dev"`, + "the dev sentinel must be gone once a real version is stamped") + }) + } +} + +// A dev build serves the spec untouched: info.version stays "dev" (local +// `go run` and any docker build without --build-arg VERSION report dev). +func TestDocsHandler_DevBuildLeavesSentinel(t *testing.T) { + h := rest.DocsHandler("dev") + rr := docsGet(t, h, "/milpacs.swagger.json") + require.Equal(t, http.StatusOK, rr.Code) + + var doc struct { + Info struct { + Version string `json:"version"` + } `json:"info"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &doc)) + assert.Equal(t, "dev", doc.Info.Version) +} + +// The Swagger UI shell and its static assets serve verbatim from the embedded +// filesystem at the same URLs the old gateway used (everything outside /api). +func TestDocsHandler_ServesSwaggerUIShell(t *testing.T) { + h := rest.DocsHandler("dev") + + rr := docsGet(t, h, "/") + require.Equal(t, http.StatusOK, rr.Code) + assert.True(t, strings.Contains(rr.Body.String(), "swagger") || + strings.Contains(rr.Body.String(), "Swagger"), + "the index must be the Swagger UI shell") +} diff --git a/rest/sentry.go b/rest/sentry.go index bb69494..fe06f7b 100644 --- a/rest/sentry.go +++ b/rest/sentry.go @@ -102,7 +102,18 @@ func SetupSentry(release string) bool { return false } - Info.Println("Sentry error capture enabled (errors only), release:", release) + // Warn-only reachability pre-check: catches the misconfig class the probe + // below structurally cannot (fast send failures drain the queue and so + // still "flush"). Never changes the enabled/degraded semantics. + sentryDialCheck(dsn) + + // Probe failure still returns true — enabled-degraded, not disabled: a + // slow-network false positive must not turn off capture. Do NOT refactor + // this into `return sentryStartupProbe()`. + if sentryStartupProbe() { + Info.Println("Sentry error capture enabled (errors only), release:", release, + "— startup probe flushed (queue drained; delivery not verified — set SENTRY_DEBUG=true to confirm)") + } return true } diff --git a/rest/sentry_boot.go b/rest/sentry_boot.go new file mode 100644 index 0000000..43ad548 --- /dev/null +++ b/rest/sentry_boot.go @@ -0,0 +1,139 @@ +package rest + +// Sentry boot-lifecycle wiring for the new stack: the three pieces ported from +// Phase 0's servers/sentry.go at the single-listener cutover (#134). SetupSentry +// (sentry.go) calls sentryDialCheck and sentryStartupProbe at boot; the +// composition root installs FlushSentryOnShutdown when capture is enabled. +// +// Kept in their own file so the always-on init (sentry.go) and the boot/shutdown +// lifecycle stay legible apart; they share the package's sentryTransport test +// seam and sentryDebugEnabled. + +import ( + "net" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/getsentry/sentry-go" +) + +// sentryShutdownFlushTimeout bounds how long shutdown waits for buffered +// events to reach Sentry before the process exits. +const sentryShutdownFlushTimeout = 2 * time.Second + +// sentryStartupProbeTimeout bounds the boot-time drain check. A var, not a +// const, only so tests can shrink the window; production never mutates it. +var sentryStartupProbeTimeout = 5 * time.Second + +// sentryDialCheckTimeout bounds the boot-time TCP reachability pre-check of +// the DSN ingest host — generous enough for a cold DNS resolve plus a +// cross-region handshake, small enough to keep the worst-case boot delay +// acceptable (it stacks with the probe window before any listener opens). A +// var, not a const, only so tests can shrink the window; production never +// mutates it. +var sentryDialCheckTimeout = 3 * time.Second + +// sentryDialCheck is a boot-time, warn-only TCP reachability check of the DSN +// ingest host. It exists because the startup probe cannot see fast send +// failures — DNS errors and refused connections drain the transport queue in +// milliseconds, so the probe's flush still reports success (see +// sentryStartupProbe). A failed dial here names the unreachable host while +// the probe would stay silent. Warn-only by design: the network may heal, and +// a boot-time blip must not disable capture. +func sentryDialCheck(rawDSN string) { + dsn, err := sentry.NewDsn(rawDSN) + if err != nil { + // Unreachable in practice — sentry.Init already parsed this DSN. + return + } + addr := net.JoinHostPort(dsn.GetHost(), strconv.Itoa(dsn.GetPort())) + conn, err := net.DialTimeout("tcp", addr, sentryDialCheckTimeout) + if err != nil { + Warn.Printf("sentry DSN host %s is unreachable (%v) — error events will not be delivered; set SENTRY_DEBUG=true for per-event transport diagnostics", addr, err) + return + } + _ = conn.Close() +} + +// sentryStartupProbe pushes one canary event through the real transport and +// flushes. sentry.Init does no network I/O, so without this the pipeline is +// first exercised by the first real error. +// +// What a true return PROVES — per the SDK's documented Flush contract (queue +// drained, NOT delivered): the transport finished its send attempts within +// the window. That catches hang-class failures (blackholed egress, connects +// slower than the window) and guarantees one event exercised the full +// pipeline so SENTRY_DEBUG has something to report. What it does NOT prove: +// delivery. The transport worker dequeues on ANY send outcome, so DNS +// failures, refused connections, Sentry-side rejections (bad DSN key → 4xx) +// and rate-limit drops all complete in milliseconds, drain the queue, and +// "flush" successfully — those classes are visible only with +// SENTRY_DEBUG=true (and partially via sentryDialCheck). +func sentryStartupProbe() bool { + // Event hygiene: a fixed fingerprint + info level fold every container + // restart into one low-severity Sentry issue instead of resolve→reopen + // churn; the probe tag makes canaries filterable. Cloned hub so none of + // this leaks into the global scope. + var id *sentry.EventID + hub := sentry.CurrentHub().Clone() + hub.WithScope(func(scope *sentry.Scope) { + scope.SetFingerprint([]string{"sentry-startup-probe"}) + scope.SetTag("probe", "true") + scope.SetLevel(sentry.LevelInfo) + id = hub.CaptureMessage("sentry startup probe") + }) + if id == nil { + // Nil event ID = the client dropped the event before the transport + // saw it (e.g. a BeforeSend veto) — nothing queued, so a "successful" + // flush below would be vacuous. + Warn.Println("sentry startup probe was dropped client-side — no canary reached the transport (check BeforeSend/sampling)") + return false + } + if !hub.Flush(sentryStartupProbeTimeout) { + Warn.Println("sentry enabled but startup probe did not flush — transport still busy after the window; events may not be reaching Sentry (check DSN/egress, or set SENTRY_DEBUG=true)") + return false + } + return true +} + +// FlushSentryOnShutdown installs a signal handler that flushes buffered +// Sentry events before the process exits. The stack has no graceful shutdown +// path (Start blocks on Serve and the process dies by signal); this is the +// minimal hook so error events from the final moments are not lost. The +// composition root calls it only when SetupSentry returned true, so the no-DSN +// path keeps the default signal behavior exactly — guarded here as well as at +// the call site, because installing the handler without a client would +// silently rewrite the process's signal semantics for nothing. +func FlushSentryOnShutdown() { + if sentry.CurrentHub().Client() == nil { + // Only reachable on programmer error — the call site gates on + // SetupSentry. Loud so misuse never silently skips the handler. + Warn.Println("FlushSentryOnShutdown called without an initialised sentry client — shutdown flush handler not installed") + return + } + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + go watchShutdown(signals, + func() bool { return sentry.Flush(sentryShutdownFlushTimeout) }, + os.Exit, + ) +} + +// watchShutdown waits for a shutdown signal, flushes, then exits 0. Split +// from FlushSentryOnShutdown so the flush-before-exit ordering is testable. +// Note on os.Exit: deferred functions do not run, and a second signal +// arriving during the flush window is absorbed by the still-installed +// handler — the process is already on its way down. +func watchShutdown(signals <-chan os.Signal, flush func() bool, exit func(code int)) { + sig := <-signals + Info.Printf("received %v — flushing sentry before exit", sig) + if !flush() { + // "Remaining", not "all": events sent before the window closed made + // it out — only what was still buffered is gone. + Warn.Println("sentry shutdown flush window expired — remaining buffered events lost") + } + exit(0) +} diff --git a/rest/sentry_boot_test.go b/rest/sentry_boot_test.go new file mode 100644 index 0000000..ef3cad2 --- /dev/null +++ b/rest/sentry_boot_test.go @@ -0,0 +1,303 @@ +package rest + +import ( + "bytes" + "context" + "net" + "os" + "sync" + "syscall" + "testing" + "time" + + "github.com/getsentry/sentry-go" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// drainTransport is an in-memory sentry.Transport with a controllable Flush +// outcome. flushDrains=false simulates a drain timeout (queue still busy when +// the window closes — the hang-class failure mode), NOT a delivery failure: +// per the SDK's Flush contract, fast send failures dequeue and "flush" +// successfully. It complements transportMock (sentry_test.go), whose Flush is +// fixed true — these boot-lifecycle tests need to drive the not-drained path. +type drainTransport struct { + mu sync.Mutex + events []*sentry.Event + flushDrains bool +} + +func (t *drainTransport) Configure(sentry.ClientOptions) {} +func (t *drainTransport) Flush(time.Duration) bool { return t.flushDrains } +func (t *drainTransport) FlushWithContext(context.Context) bool { return t.flushDrains } +func (t *drainTransport) Close() {} + +func (t *drainTransport) SendEvent(event *sentry.Event) { + t.mu.Lock() + defer t.mu.Unlock() + t.events = append(t.events, event) +} + +func (t *drainTransport) Events() []*sentry.Event { + t.mu.Lock() + defer t.mu.Unlock() + return append([]*sentry.Event(nil), t.events...) +} + +// bindDrainClient binds a drain-transport Sentry client to the global hub for +// the duration of the test, restoring the unbound (disabled) state afterwards. +func bindDrainClient(t *testing.T, flushDrains bool) *drainTransport { + t.Helper() + transport := &drainTransport{flushDrains: flushDrains} + client, err := sentry.NewClient(sentry.ClientOptions{Transport: transport}) + require.NoError(t, err) + sentry.CurrentHub().BindClient(client) + t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) + return transport +} + +func TestSentryStartupProbe_DrainTimeout_WarnsLoudly(t *testing.T) { + bindDrainClient(t, false) + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + ok := sentryStartupProbe() + + assert.False(t, ok) + assert.Contains(t, warnBuf.String(), "startup probe did not flush", + "a hang-class transport stall is the one failure mode the drain probe can see — it must be loud") +} + +func TestSentryStartupProbe_FlushDrains_NoWarning(t *testing.T) { + transport := bindDrainClient(t, true) + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + ok := sentryStartupProbe() + + assert.True(t, ok) + assert.Empty(t, warnBuf.String(), "a drained probe must not warn") + events := transport.Events() + require.Len(t, events, 1, "the probe must push exactly one canary event through the transport") + event := events[0] + assert.Equal(t, "sentry startup probe", event.Message) + assert.Equal(t, []string{"sentry-startup-probe"}, event.Fingerprint, + "a fixed fingerprint folds every container restart into one Sentry issue — no resolve→reopen churn") + assert.Equal(t, "true", event.Tags["probe"], "probe events must be filterable") + assert.Equal(t, sentry.LevelInfo, event.Level, "the canary is informational, never an alertable error") +} + +func TestSentryStartupProbe_ClientSideDrop_WarnsAndFails(t *testing.T) { + // A BeforeSend veto makes CaptureMessage return a nil event ID — the + // client dropped the canary before the transport ever saw it, so there is + // nothing to drain and the flush alone would report a false success. + client, err := sentry.NewClient(sentry.ClientOptions{ + Transport: &drainTransport{flushDrains: true}, + BeforeSend: func(*sentry.Event, *sentry.EventHint) *sentry.Event { return nil }, + }) + require.NoError(t, err) + sentry.CurrentHub().BindClient(client) + t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + ok := sentryStartupProbe() + + assert.False(t, ok, "a client-side drop means no canary exercised the pipeline — probe failed") + assert.Contains(t, warnBuf.String(), "dropped client-side", + "a canary that never reached the transport must be visible, not a silent flush success") +} + +// TestSetupSentry_ProbeStall_WarnsAndWithholdsEnabledLine pins that SetupSentry +// actually INVOKES the startup probe against the client it just configured: a +// drain transport injected through the sentryTransport seam reports +// Flush=not-drained, so the stalled-transport premise holds by construction — +// no socket dial manufactures the stall, and machine load cannot invert the +// outcome (#179: a refused dial is a fast send outcome that drains the queue, +// so the old local-server stall lost the timing race under load). Deleting the +// sentryStartupProbe() call from SetupSentry turns this red (no stall warning, +// the enabled line prints, no canary reaches the transport). +func TestSetupSentry_ProbeStall_WarnsAndWithholdsEnabledLine(t *testing.T) { + transport := &drainTransport{flushDrains: false} + sentryTransport = transport + t.Cleanup(func() { sentryTransport = nil }) + + // 127.0.0.1:1 refuses instantly — the suite's deterministic stand-in for + // the dial-check pre-check, which is frozen and irrelevant to the stall + // premise. Shrink its window anyway so a pathological environment cannot + // stall the suite. The probe window itself no longer needs shrinking: the + // stub's Flush reports not-drained immediately, regardless of load. + viper.Set("SENTRY_DSN", "http://public@127.0.0.1:1/1") + t.Cleanup(func() { + viper.Set("SENTRY_DSN", "") + sentry.CurrentHub().BindClient(nil) + }) + restoreDial := sentryDialCheckTimeout + sentryDialCheckTimeout = 500 * time.Millisecond + defer func() { sentryDialCheckTimeout = restoreDial }() + + var warnBuf, infoBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + Info.SetOutput(&infoBuf) + defer Info.SetOutput(os.Stdout) + + enabled := SetupSentry(testRelease) + + assert.True(t, enabled, "a stalled probe means degraded, never disabled — capture stays on") + assert.Contains(t, warnBuf.String(), "startup probe did not flush", + "a transport that cannot drain within the window must be loud at boot") + assert.NotContains(t, infoBuf.String(), "Sentry error capture enabled", + "the success line must be withheld when the probe could not drain") + events := transport.Events() + require.Len(t, events, 1, + "the canary must reach the transport of the client SetupSentry just configured — the probe ran against THAT client, not some pre-bound stub") + assert.Equal(t, "sentry startup probe", events[0].Message) +} + +func TestSentryDialCheck_UnreachableHost_WarnsWithHost(t *testing.T) { + // 127.0.0.1:1 refuses instantly — the deterministic stand-in for the + // wrong-DSN-host misconfig class the startup probe cannot see (fast send + // failures still drain the queue). Shrink the dial window anyway so a + // pathological environment cannot stall the suite. + restore := sentryDialCheckTimeout + sentryDialCheckTimeout = 500 * time.Millisecond + defer func() { sentryDialCheckTimeout = restore }() + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + sentryDialCheck("https://public@127.0.0.1:1/1") + + assert.Contains(t, warnBuf.String(), "127.0.0.1:1", + "the warning must name the unreachable host so the misconfig is actionable") + assert.Contains(t, warnBuf.String(), "SENTRY_DEBUG", + "the warning must point at the diagnostic that shows per-event send failures") +} + +func TestSentryDialCheck_ReachableHost_Silent(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = lis.Close() }() + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + sentryDialCheck("https://public@" + lis.Addr().String() + "/1") + + assert.Empty(t, warnBuf.String(), "a reachable ingest host must not warn") +} + +// TestSentryDebugEnabled_StrictParse pins the operator-trap fix: viper's +// GetBool silently maps "yes"/"on"/typos to false, which mid-incident reads +// as "debug on but nothing logged". Unparseable values must warn. +func TestSentryDebugEnabled_StrictParse(t *testing.T) { + cases := []struct { + name string + raw string + want bool + wantWarn bool + }{ + {"unset — silently off", "", false, false}, + {"true", "true", true, false}, + {"1", "1", true, false}, + {"TRUE", "TRUE", true, false}, + {"false", "false", false, false}, + {"yes — operator trap, warn", "yes", false, true}, + {"on — operator trap, warn", "on", false, true}, + {"typo — warn", "ture", false, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + viper.Set("SENTRY_DEBUG", tc.raw) + defer viper.Set("SENTRY_DEBUG", "") + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + assert.Equal(t, tc.want, sentryDebugEnabled()) + if tc.wantWarn { + assert.Contains(t, warnBuf.String(), "SENTRY_DEBUG", + "an unparseable value must be called out by name, not silently treated as off") + assert.Contains(t, warnBuf.String(), tc.raw, "the warning must echo the rejected value") + } else { + assert.Empty(t, warnBuf.String(), "parseable or unset values must not warn") + } + }) + } +} + +// TestFlushSentryOnShutdown_NoClient_WarnsAndSkips pins the guard's +// visibility: it only fires on programmer error (the call site must gate on +// SetupSentry), and silent misuse would mean shutdown flushes everyone assumes +// exist were never installed. +func TestFlushSentryOnShutdown_NoClient_WarnsAndSkips(t *testing.T) { + require.Nil(t, sentry.CurrentHub().Client(), "test requires the disabled state") + + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + FlushSentryOnShutdown() + + assert.Contains(t, warnBuf.String(), "without an initialised sentry client", + "misuse must be visible in logs, not a silent no-op") +} + +func TestWatchShutdown_FlushesBeforeExit(t *testing.T) { + cases := []struct { + name string + flushOK bool + wantWarn bool + }{ + {"flush succeeds — silent", true, false}, + {"flush times out — dropped events are visible", false, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var warnBuf bytes.Buffer + Warn.SetOutput(&warnBuf) + defer Warn.SetOutput(os.Stdout) + + signals := make(chan os.Signal, 1) + done := make(chan struct{}) + var order []string + + go watchShutdown(signals, + func() bool { + order = append(order, "flush") + return tc.flushOK + }, + func(code int) { + assert.Equal(t, 0, code, "graceful shutdown must exit 0") + order = append(order, "exit") + close(done) + }) + + signals <- syscall.SIGTERM + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("watchShutdown never exited after the signal") + } + assert.Equal(t, []string{"flush", "exit"}, order, "buffered events must be flushed before the process exits") + if tc.wantWarn { + assert.Contains(t, warnBuf.String(), "shutdown flush window expired — remaining buffered events lost", + "silently dropped events at shutdown must leave a trace in the logs") + } else { + assert.NotContains(t, warnBuf.String(), "flush window expired", "a clean flush must not warn") + } + }) + } +} diff --git a/rest/sentry_test.go b/rest/sentry_test.go index 2794fcb..786f119 100644 --- a/rest/sentry_test.go +++ b/rest/sentry_test.go @@ -103,6 +103,15 @@ func (t *transportMock) Events() []*sentry.Event { return append([]*sentry.Event(nil), t.events...) } +// reset drops any captured events. enableSentry calls it after SetupSentry so +// the boot-time startup-probe canary (#134: SetupSentry now drives the probe) +// does not count toward a test's own event assertions. +func (t *transportMock) reset() { + t.mu.Lock() + defer t.mu.Unlock() + t.events = nil +} + // testRelease is the build-time version stand-in tests pass to SetupSentry. const testRelease = "v-test-132" @@ -124,6 +133,7 @@ func enableSentry(t *testing.T) *transportMock { viper.Set("SENTRY_DSN", "") }) require.True(t, SetupSentry(testRelease), "SetupSentry must enable capture when SENTRY_DSN is set") + tr.reset() // discard the boot-time startup-probe canary so tests count only their own events return tr } From 0c5f11a9ce1d6355c1c028523f402f68ba1cedf1 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:47:42 -0400 Subject: [PATCH 5/8] feat(servers): single-listener composition root, drop the gRPC stack Rebuild servers.Start around one public listener and one internal metrics listener. The public :11000 listener serves rest.New(ds, rc) under /api and DocsHandler everywhere else, at the same URLs as before. The metrics handler binds on its own :9090 listener. The :10000 gRPC listener and the grpc-gateway translation layer are gone. InitTrustedProxies stays first, fatal on error (ADR 0005). The reference cache still warms before serving (New + Refresh + the refresh goroutine), since rest.New panics on a cold cache. SetupSentry(version) replaces the old Phase 0 setupSentry, and FlushSentryOnShutdown installs only when capture is enabled. Delete servers/grpc and servers/gateway whole, plus servers/sentry.go and its test. PORT was only the gateway's gRPC dial target, so servers.New takes no address and serve.go no longer reads it. cmd/example.go is the only remaining proto importer, left for Phase 4. > *This was generated by AI* --- cmd/serve.go | 14 +- servers/gateway/gateway.go | 144 -------- servers/gateway/gateway_test.go | 114 ------- servers/gateway/sentry.go | 108 ------ servers/gateway/sentry_test.go | 306 ----------------- servers/grpc/auth.go | 37 --- servers/grpc/auth_test.go | 212 ------------ servers/grpc/authentication.go | 84 ----- servers/grpc/authentication_test.go | 103 ------ servers/grpc/fake_datastore_test.go | 111 ------- servers/grpc/grpc.go | 244 -------------- servers/grpc/milpacs_test.go | 238 -------------- servers/grpc/sentry.go | 108 ------ servers/grpc/sentry_test.go | 331 ------------------- servers/grpc/tickets.go | 128 -------- servers/grpc/tickets_test.go | 223 ------------- servers/sentry.go | 267 --------------- servers/sentry_test.go | 488 ---------------------------- servers/server.go | 167 ++++------ servers/server_test.go | 8 +- 20 files changed, 81 insertions(+), 3354 deletions(-) delete mode 100644 servers/gateway/gateway.go delete mode 100644 servers/gateway/gateway_test.go delete mode 100644 servers/gateway/sentry.go delete mode 100644 servers/gateway/sentry_test.go delete mode 100644 servers/grpc/auth.go delete mode 100644 servers/grpc/auth_test.go delete mode 100644 servers/grpc/authentication.go delete mode 100644 servers/grpc/authentication_test.go delete mode 100644 servers/grpc/fake_datastore_test.go delete mode 100644 servers/grpc/grpc.go delete mode 100644 servers/grpc/milpacs_test.go delete mode 100644 servers/grpc/sentry.go delete mode 100644 servers/grpc/sentry_test.go delete mode 100644 servers/grpc/tickets.go delete mode 100644 servers/grpc/tickets_test.go delete mode 100644 servers/sentry.go delete mode 100644 servers/sentry_test.go diff --git a/cmd/serve.go b/cmd/serve.go index 1a1a74a..c4a8546 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -19,22 +19,20 @@ package cmd import ( - "fmt" "github.com/7cav/api/servers" "github.com/spf13/cobra" - "github.com/spf13/viper" ) // serveCmd represents the serve command var serveCmd = &cobra.Command{ Use: "serve", - Short: "Launches the api servers", + Short: "Launches the api server", Run: func(cmd *cobra.Command, args []string) { - // PORT is the port the HTTP gateway dials to reach the in-process gRPC server. - // It is NOT a listen port: both the gRPC server (:10000) and HTTP gateway (:11000) listen - // ports are hardcoded in servers/server.go. PORT must match the hardcoded gRPC port (10000) - // for the gateway-to-gRPC dial to succeed. - server := servers.New(fmt.Sprintf("0.0.0.0:%s", viper.GetString("port"))) + // The public (:11000) and internal metrics (:9090) listen ports are + // constants in servers/server.go. The old PORT env var was only the + // gateway's gRPC dial target; the gRPC server is gone (#134), so PORT is + // no longer read. + server := servers.New() server.Start() }, } diff --git a/servers/gateway/gateway.go b/servers/gateway/gateway.go deleted file mode 100644 index 76c1512..0000000 --- a/servers/gateway/gateway.go +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package gateway - -import ( - "context" - "io/fs" - "log" - "mime" - "net/http" - "os" - "strings" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/openapi" - "github.com/7cav/api/proto" - "github.com/7cav/api/rest" - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "google.golang.org/grpc" -) - -type Service struct { - Address string - Datastore datastores.Datastore -} - -var ( - Info = log.New(os.Stdout, "INFO: ", log.LstdFlags) - Warn = log.New(os.Stdout, "WARNING: ", log.LstdFlags) - Error = log.New(os.Stdout, "ERROR: ", log.LstdFlags) -) - -func getOpenAPIHandler() http.Handler { - Info.Println("setting up OpenAPI Handler") - if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil { - Error.Println("failed to add MIME extension type for .svg: ", err) - } - sub, err := fs.Sub(openapi.Files, "assets") - if err != nil { - Error.Println("creating OpenAPI sub-filesystem: ", err) - } - return http.FileServer(http.FS(sub)) -} - -// buildAPIHandler assembles the /api middleware chain: -// auth(sentry(compression(inner))). Sentry sits inside auth so it only -// sees authenticated requests, with the API key already on ctx for key-id -// tagging, and outside the compression layer so it observes the final -// response status. No SENTRY_DSN → it is a pass-through. -// -// The auth and compression layers are the rest package's middleware (the -// Phase 3 stack, #125): they moved there verbatim — single source, so the -// two stacks cannot diverge while both are in-tree — and this gateway -// delegates until cutover deletes it. rest.AuthMiddleware attaches the -// validated key with rest.ContextWithKey; grpcServices.KeyFromContext -// delegates to the same context key, so the Sentry key-id tagging below -// keeps seeing it. -// -// Phase 2 de-cache (#123/#124): the response cache is gone — middleware out -// of the chain at #123 (taking the X-Cache header with it — the PRD's -// enumerated break), the cache package and Redis deleted at #124. See ADR -// 0003 (superseded). -// -// Sentry-inside-auth also means auth-layer infrastructure failures (e.g. a -// datastore outage producing mass 401s) generate no Sentry events by design — -// accepted for Phase 0, revisit in the Phase 3 observability slices -// (#130–#132). -// -// Package-level (not inlined in Server) so the chain order is a tested -// contract — see the buildAPIHandler tests — rather than an untestable -// expression inside a dialing function. -func buildAPIHandler(ds datastores.Datastore, inner http.Handler) http.Handler { - return rest.AuthMiddleware(ds, - sentryMiddleware(rest.GzipMiddleware(inner))) -} - -func (service *Service) Server() *http.Server { - // relevant Grpc _dialing_ options - // note: commenting out the TransportCredentials option, because internally (nginx <-> golang) traffic is not encrypted. - // If this needed to change in the future, then we will need to refactor this method - - var sendMessageInMB = 20 - - conn, err := grpc.DialContext( - context.Background(), - "dns:///"+service.Address, - grpc.WithBlock(), - grpc.WithInsecure(), - grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(1024*1024*sendMessageInMB)), - //grpc.WithTransportCredentials(creds), - ) - - if err != nil { - Error.Println("failed to dial servers: ", err) - return nil - } - - gwMux := runtime.NewServeMux() - err = proto.RegisterMilpacServiceHandler(context.Background(), gwMux, conn) - - if err != nil { - Error.Println("failed to register gateway: ", err) - return nil - } - - err = proto.RegisterTicketsServiceHandler(context.Background(), gwMux, conn) - if err != nil { - Error.Println("failed to register tickets gateway: ", err) - return nil - } - - openApi := getOpenAPIHandler() - - handler := buildAPIHandler(service.Datastore, gwMux) - - // if requests start with /api then forward it on to the grpc-gateway client - // otherwise, just serve it as norma (basically the OpenAPI) - return &http.Server{ - Addr: service.Address, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api") { - handler.ServeHTTP(w, r) - return - } - openApi.ServeHTTP(w, r) - }), - } -} diff --git a/servers/gateway/gateway_test.go b/servers/gateway/gateway_test.go deleted file mode 100644 index d2fc922..0000000 --- a/servers/gateway/gateway_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package gateway - -import ( - "compress/gzip" - "io" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "github.com/7cav/api/datastores" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// fakeAuthDatastore embeds the Datastore interface so it satisfies the type -// without implementing every method; only ValidateApiKey is exercised by the -// auth layer (rest.AuthMiddleware — moved there at #125, tested there). Any -// other call panics (nil method) — a loud failure if a test accidentally -// reaches further into the datastore. -type fakeAuthDatastore struct { - datastores.Datastore - validateApiKey func(string) (*datastores.ApiKeyResult, error) -} - -func (f *fakeAuthDatastore) ValidateApiKey(rawKey string) (*datastores.ApiKeyResult, error) { - return f.validateApiKey(rawKey) -} - -// TestBuildAPIHandler_ResponseCacheRemoved pins the Phase 2 de-cache -// (#123/#124): the response cache is gone — middleware out of the /api chain -// at #123, the cache package and Redis deleted outright at #124. Observable -// contract, driven through the production chain constructor on a formerly -// cacheable (non-tickets) GET route: -// -// - every request reaches the inner handler — nothing is served from a -// cache; -// - the X-Cache header is gone (the PRD's enumerated break for this slice); -// - the response body passes through unbuffered and intact. -func TestBuildAPIHandler_ResponseCacheRemoved(t *testing.T) { - ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - return &datastores.ApiKeyResult{KeyId: 42}, nil - }} - - innerCalls := 0 - h := buildAPIHandler(ds, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - innerCalls++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"roster":"live"}`)) - })) - - for i := 1; i <= 2; i++ { - req := httptest.NewRequest(http.MethodGet, "/api/v1/roster/cbt", nil) - req.Header.Set("Authorization", "Bearer cav7_goodkey") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - - require.Equal(t, http.StatusOK, rr.Code) - assert.Empty(t, rr.Header().Values("X-Cache"), - "X-Cache must be gone — the enumerated Phase 2 break (#123)") - assert.Equal(t, `{"roster":"live"}`, rr.Body.String(), - "response must pass through the chain untouched") - assert.Equal(t, i, innerCalls, - "every request must reach the inner handler — no response cache in the chain") - } -} - -// TestBuildAPIHandler_GzipRoundTrip pins the compression layer, live on every -// /api route since the Phase 2 de-cache (#123) — before that, the cache -// middleware stripped Accept-Encoding and gzipped responses itself, so on -// cacheable routes this path never ran in production (only the tickets -// bypass reached it). The assertion is a real round-trip — the body -// must decompress back to the original payload through an intact gzip trailer -// — because the failure mode this guards (gzipResponseWriter.Write dropping -// the stale uncompressed Content-Length too late or not at all) corrupts the -// body while the status stays 200; a status-only check would pass against the -// exact bug. -func TestBuildAPIHandler_GzipRoundTrip(t *testing.T) { - ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - return &datastores.ApiKeyResult{KeyId: 42}, nil - }} - - const payload = `{"roster":"live","unit":"7th Cavalry","status":"active"}` - h := buildAPIHandler(ds, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - // A length-aware inner handler sets the UNCOMPRESSED length; the - // compression layer must strip it or clients truncate the gzip body. - w.Header().Set("Content-Length", strconv.Itoa(len(payload))) - _, _ = w.Write([]byte(payload)) - })) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/roster/cbt", nil) - req.Header.Set("Authorization", "Bearer cav7_goodkey") - req.Header.Set("Accept-Encoding", "gzip") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - - require.Equal(t, http.StatusOK, rr.Code) - // Assert on Result().Header — the snapshot taken when the response was - // committed, i.e. what the client actually received — not the recorder's - // live header map, which would also reflect a Del that happened too late - // to make it onto the wire. - assert.Equal(t, "gzip", rr.Result().Header.Get("Content-Encoding")) - assert.Empty(t, rr.Result().Header.Get("Content-Length"), - "stale uncompressed Content-Length must be stripped") - - zr, err := gzip.NewReader(rr.Body) - require.NoError(t, err, "body must be a valid gzip stream") - decoded, err := io.ReadAll(zr) - require.NoError(t, err, "gzip body must decompress cleanly") - require.NoError(t, zr.Close(), "gzip trailer (CRC + size) must be intact") - assert.Equal(t, payload, string(decoded), - "gunzipped body must round-trip to the original payload") -} diff --git a/servers/gateway/sentry.go b/servers/gateway/sentry.go deleted file mode 100644 index 5d4d4c1..0000000 --- a/servers/gateway/sentry.go +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package gateway - -import ( - "fmt" - "net/http" - "strconv" - - grpcServices "github.com/7cav/api/servers/grpc" - "github.com/getsentry/sentry-go" -) - -// sentryMiddleware reports handler panics and 5xx responses to Sentry, tagged -// with the route and the calling API key id (never the bearer token). Expects -// to run inside auth so the validated key is already on the request ctx; if -// it is absent, the event simply omits the key_id tag. -// -// An Internal-class error on an HTTP-originated request produces two events — -// the gRPC interceptor's exception plus this HTTP message, sharing the key_id -// but with distinct transport tags. Deliberate Phase 0 wrap-both-ends -// behavior. -// -// Without an initialised Sentry client (no SENTRY_DSN) it is a pass-through: -// responses flow unchanged and panics propagate exactly as they do today -// (net/http recovers them per-connection). Panics are re-raised after capture -// either way — Phase 0 observes, it does not change crash semantics (PRD #112). -func sentryMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if sentry.CurrentHub().Client() == nil { - next.ServeHTTP(w, r) - return - } - - hub := sentry.CurrentHub().Clone() - scope := hub.Scope() - scope.SetTag("transport", "http") - scope.SetTag("route", r.URL.Path) - if key := grpcServices.KeyFromContext(r.Context()); key != nil { - scope.SetTag("key_id", strconv.FormatUint(uint64(key.KeyId), 10)) - } - - sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} - - defer func() { - if rec := recover(); rec != nil { - hub.RecoverWithContext(r.Context(), rec) // event queued; ID unused — async transport - // Re-raise: net/http's per-connection recovery handles it - // exactly as it does today. The process survives, so the - // async transport delivers the event — no flush needed. - panic(rec) - } - if sw.status >= http.StatusInternalServerError { - scope.SetTag("http_status", strconv.Itoa(sw.status)) - scope.SetLevel(sentry.LevelError) - // Group by method+status, not URL: parameterized paths - // (/profile/{id}) must not fan one failure into N Sentry - // issues. The raw path stays available on the route tag. - scope.SetFingerprint([]string{"http-5xx", r.Method, strconv.Itoa(sw.status)}) - hub.CaptureMessage(fmt.Sprintf("HTTP %d %s %s", sw.status, r.Method, r.URL.Path)) // event queued; ID unused — async transport - } - }() - - next.ServeHTTP(sw, r) - }) -} - -// statusRecorder captures the status the client actually saw for the -// deferred 5xx check: the first explicit WriteHeader wins, and a body Write -// without one latches the implicit 200 — mirroring net/http, where any later -// WriteHeader is a superfluous no-op. -type statusRecorder struct { - http.ResponseWriter - status int - wroteHeader bool -} - -func (sr *statusRecorder) WriteHeader(code int) { - if !sr.wroteHeader { - sr.status = code - sr.wroteHeader = true - } - sr.ResponseWriter.WriteHeader(code) -} - -// Write latches the implicit 200 (status already defaults to it) so a buggy -// Write-then-WriteHeader(500) handler cannot record a status the client -// never received. -func (sr *statusRecorder) Write(b []byte) (int, error) { - sr.wroteHeader = true - return sr.ResponseWriter.Write(b) -} diff --git a/servers/gateway/sentry_test.go b/servers/gateway/sentry_test.go deleted file mode 100644 index b6e80fc..0000000 --- a/servers/gateway/sentry_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package gateway - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/rest" - "github.com/getsentry/sentry-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// captureTransport is an in-memory sentry.Transport recording every event the -// client would have sent over the wire — the observable seam for these tests. -// Flush here always drains (per the SDK contract Flush reports queue drain, -// not delivery); flushed records whether anything ever flushed synchronously, -// pinning the HTTP side of the deliberate sync/async flush asymmetry (gRPC -// panics flush, HTTP panics must not — the process survives and the async -// transport sends). -type captureTransport struct { - mu sync.Mutex - events []*sentry.Event - flushed bool -} - -func (t *captureTransport) Configure(sentry.ClientOptions) {} - -func (t *captureTransport) Flush(time.Duration) bool { - t.mu.Lock() - defer t.mu.Unlock() - t.flushed = true - return true -} - -func (t *captureTransport) FlushWithContext(context.Context) bool { - t.mu.Lock() - defer t.mu.Unlock() - t.flushed = true - return true -} - -func (t *captureTransport) Close() {} - -func (t *captureTransport) Flushed() bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.flushed -} - -func (t *captureTransport) SendEvent(event *sentry.Event) { - t.mu.Lock() - defer t.mu.Unlock() - t.events = append(t.events, event) -} - -func (t *captureTransport) Events() []*sentry.Event { - t.mu.Lock() - defer t.mu.Unlock() - return append([]*sentry.Event(nil), t.events...) -} - -// testRelease stands in for the build-time version injection so tests can -// prove events carry the release tag. -const testRelease = "test-release-1.2.3" - -// bindCaptureClient binds a capture-only Sentry client to the global hub for -// the duration of the test, restoring the unbound (disabled) state afterwards. -// AttachStacktrace mirrors production (setupSentry) so string-panic events -// carry frames here exactly as they do live. -func bindCaptureClient(t *testing.T) *captureTransport { - t.Helper() - transport := &captureTransport{} - client, err := sentry.NewClient(sentry.ClientOptions{Transport: transport, Release: testRelease, AttachStacktrace: true}) - require.NoError(t, err) - sentry.CurrentHub().BindClient(client) - t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) - return transport -} - -func TestSentryMiddleware_500Response_CapturedWithRouteAndStatus(t *testing.T) { - transport := bindCaptureClient(t) - h := sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream exploded", http.StatusInternalServerError) - })) - - rr := httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/roster", nil)) - - assert.Equal(t, http.StatusInternalServerError, rr.Code, "response must pass through untouched") - - events := transport.Events() - require.Len(t, events, 1, "a 5xx response must produce exactly one event") - event := events[0] - assert.Equal(t, "/api/v1/roster", event.Tags["route"]) - assert.Equal(t, "500", event.Tags["http_status"]) - assert.Equal(t, "http", event.Tags["transport"]) - assert.Equal(t, sentry.LevelError, event.Level) - assert.Equal(t, []string{"http-5xx", "GET", "500"}, event.Fingerprint, - "grouping must be method+status, not URL — parameterized paths must not fan one failure into N issues") -} - -// TestSentryMiddleware_WriteThenWriteHeader_Records200 pins the implicit-200 -// latch: once a handler writes the body, net/http has committed status 200, -// and a buggy late WriteHeader(500) must not record a false 500. -func TestSentryMiddleware_WriteThenWriteHeader_Records200(t *testing.T) { - transport := bindCaptureClient(t) - h := sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{}`)) // commits the implicit 200 - w.WriteHeader(http.StatusInternalServerError) - })) - - rr := httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/roster", nil)) - - assert.Equal(t, http.StatusOK, rr.Code, "the committed implicit 200 is what the client saw") - assert.Empty(t, transport.Events(), "a status the client never received must not produce an event") -} - -func TestSentryMiddleware_NonServerErrorResponses_NoEvents(t *testing.T) { - transport := bindCaptureClient(t) - - cases := []struct { - name string - handle http.HandlerFunc - want int - }{ - {"implicit 200", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{}`)) // no explicit WriteHeader - }, http.StatusOK}, - {"explicit 200", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }, http.StatusOK}, - {"404", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "no such profile", http.StatusNotFound) - }, http.StatusNotFound}, - {"401", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - }, http.StatusUnauthorized}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rr := httptest.NewRecorder() - sentryMiddleware(tc.handle).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/roster", nil)) - assert.Equal(t, tc.want, rr.Code) - }) - } - - assert.Empty(t, transport.Events(), "non-5xx responses must not generate Sentry noise") -} - -func TestSentryMiddleware_HandlerPanic_CapturedThenRepanics(t *testing.T) { - transport := bindCaptureClient(t) - h := sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - panic("kaboom") - })) - - assert.PanicsWithValue(t, "kaboom", func() { - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/v1/tickets", nil)) - }, "panic must propagate so net/http's per-connection recovery behaves as today") - - events := transport.Events() - require.Len(t, events, 1, "a handler panic must produce exactly one event") - event := events[0] - assert.Equal(t, "/api/v1/tickets", event.Tags["route"]) - assert.Equal(t, "http", event.Tags["transport"]) - assert.Equal(t, sentry.LevelFatal, event.Level) - assert.Equal(t, testRelease, event.Release, "panic events must carry the release") - assert.False(t, transport.Flushed(), - "HTTP panics must NOT flush synchronously — the process survives and the async transport delivers") - require.NotEmpty(t, event.Threads, "a string panic must carry a stack (AttachStacktrace shapes it as a thread)") - require.NotNil(t, event.Threads[0].Stacktrace) - assert.NotEmpty(t, event.Threads[0].Stacktrace.Frames, "a string panic without frames is undebuggable") -} - -func TestSentryMiddleware_NoClient_PassThrough(t *testing.T) { - // No client bound — the no-DSN state. Responses and panics must behave - // exactly as they do today. - require.Nil(t, sentry.CurrentHub().Client(), "test requires the disabled state") - - rr := httptest.NewRecorder() - sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream exploded", http.StatusInternalServerError) - })).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/roster", nil)) - assert.Equal(t, http.StatusInternalServerError, rr.Code) - - assert.PanicsWithValue(t, "kaboom", func() { - sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - panic("kaboom") - })).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/v1/roster", nil)) - }, "panics must still propagate when Sentry is disabled") -} - -func TestSentryMiddleware_BearerTokenNeverInPayload(t *testing.T) { - transport := bindCaptureClient(t) - const secret = "cav7_topsecrettokenvalue" - - makeReq := func() *http.Request { - // Secret in BOTH vectors — header and query string — so the - // whole-payload sweep below also pins the query-borne path - // (?api_key=...), not just the Authorization header. - req := httptest.NewRequest(http.MethodGet, "/api/v1/roster?api_key="+secret, nil) - req.Header.Set("Authorization", "Bearer "+secret) - return req - } - - sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream exploded", http.StatusInternalServerError) - })).ServeHTTP(httptest.NewRecorder(), makeReq()) - assert.PanicsWithValue(t, "kaboom", func() { - sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - panic("kaboom") - })).ServeHTTP(httptest.NewRecorder(), makeReq()) - }) - - events := transport.Events() - require.Len(t, events, 2) - for _, event := range events { - payload, err := json.Marshal(event) - require.NoError(t, err) - assert.NotContains(t, string(payload), secret, "bearer material must never appear in any event payload") - } -} - -// TestBuildAPIHandler_500BehindValidAuth_OneEventWithKeyID drives the actual -// production chain constructor — auth(sentry(compression)) — end to end: a -// 500 from the inner handler behind valid auth must produce exactly one -// event carrying the key_id auth attached. -func TestBuildAPIHandler_500BehindValidAuth_OneEventWithKeyID(t *testing.T) { - transport := bindCaptureClient(t) - ds := &fakeAuthDatastore{validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - assert.Equal(t, "cav7_goodkey", token) - return &datastores.ApiKeyResult{KeyId: 42}, nil - }} - h := buildAPIHandler(ds, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream exploded", http.StatusInternalServerError) - })) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/tickets", nil) - req.Header.Set("Authorization", "Bearer cav7_goodkey") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) - events := transport.Events() - require.Len(t, events, 1, "one 500 through the full chain must mean exactly one event") - assert.Equal(t, "42", events[0].Tags["key_id"], "the chain order is the contract: auth must run before sentry") - assert.Equal(t, "500", events[0].Tags["http_status"]) -} - -// TestBuildAPIHandler_BadAuth_401NoEvents pins the other side of the chain -// order: rejected requests never reach Sentry (or the inner handler). -func TestBuildAPIHandler_BadAuth_401NoEvents(t *testing.T) { - transport := bindCaptureClient(t) - ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - return nil, nil // zero rows — invalid key - }} - innerCalled := false - h := buildAPIHandler(ds, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - innerCalled = true - })) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/tickets", nil) - req.Header.Set("Authorization", "Bearer cav7_badkey") - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusUnauthorized, rr.Code) - assert.False(t, innerCalled, "auth must reject before anything inner runs") - assert.Empty(t, transport.Events(), "auth rejections are expected behavior, not Sentry events") -} - -// TestSentryMiddleware_BehindAuth_EventCarriesKeyID exercises the real chain -// shape — rest.AuthMiddleware outside, sentryMiddleware inside — and proves -// the validated API key id reaches the event while the raw key never does. -func TestSentryMiddleware_BehindAuth_EventCarriesKeyID(t *testing.T) { - transport := bindCaptureClient(t) - const secret = "cav7_topsecrettokenvalue" - - ds := &fakeAuthDatastore{validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - assert.Equal(t, secret, token) - return &datastores.ApiKeyResult{KeyId: 42, UserId: 7}, nil - }} - h := rest.AuthMiddleware(ds, sentryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream exploded", http.StatusInternalServerError) - }))) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/roster", nil) - req.Header.Set("Authorization", "Bearer "+secret) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) - events := transport.Events() - require.Len(t, events, 1) - event := events[0] - assert.Equal(t, "42", event.Tags["key_id"], "events carry the key id, not the key") - payload, err := json.Marshal(event) - require.NoError(t, err) - assert.NotContains(t, string(payload), secret, "bearer material must never appear in any event payload") -} diff --git a/servers/grpc/auth.go b/servers/grpc/auth.go deleted file mode 100644 index 67edb2d..0000000 --- a/servers/grpc/auth.go +++ /dev/null @@ -1,37 +0,0 @@ -package grpc - -import ( - "context" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/rest" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// The key-on-context helpers moved to the rest package (the Phase 3 stack, -// #125) — their permanent home once this package is deleted at Phase 4. The -// delegating aliases below keep this package's API stable and, more -// importantly, keep BOTH stacks attaching/reading the same context key, so -// the gateway's Sentry key-id tagging works regardless of which middleware -// authenticated the request. - -// ContextWithKey returns a new context carrying the given API key result. -func ContextWithKey(ctx context.Context, key *datastores.ApiKeyResult) context.Context { - return rest.ContextWithKey(ctx, key) -} - -// KeyFromContext returns the *ApiKeyResult attached to ctx, or nil if none. -func KeyFromContext(ctx context.Context) *datastores.ApiKeyResult { - return rest.KeyFromContext(ctx) -} - -// RequireScope returns codes.PermissionDenied if the key on ctx lacks the named -// scope. Call at the top of every RPC that requires authorization. -func RequireScope(ctx context.Context, scope string) error { - key := KeyFromContext(ctx) - if !key.HasScope(scope) { - return status.Errorf(codes.PermissionDenied, "scope required: %s", scope) - } - return nil -} diff --git a/servers/grpc/auth_test.go b/servers/grpc/auth_test.go deleted file mode 100644 index b307626..0000000 --- a/servers/grpc/auth_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package grpc - -import ( - "bytes" - "context" - "net" - "testing" - - "github.com/7cav/api/datastores" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/peer" - "google.golang.org/grpc/status" -) - -// captureInfo redirects the package Info logger to a buffer for the duration -// of the test and restores it on cleanup. Not parallel-safe: swaps a shared -// package-level logger; do not add t.Parallel() to this package. -func captureInfo(t *testing.T) *bytes.Buffer { - t.Helper() - buf := &bytes.Buffer{} - prev := Info.Writer() - Info.SetOutput(buf) - t.Cleanup(func() { Info.SetOutput(prev) }) - return buf -} - -func TestKeyFromContext_NilWhenAbsent(t *testing.T) { - got := KeyFromContext(context.Background()) - assert.Nil(t, got) -} - -func TestKeyFromContext_RoundTrip(t *testing.T) { - key := &datastores.ApiKeyResult{ - KeyId: 1, - UserId: 2, - Scopes: map[string]struct{}{"read": {}}, - } - ctx := ContextWithKey(context.Background(), key) - got := KeyFromContext(ctx) - require.NotNil(t, got) - assert.Equal(t, key, got) -} - -func TestRequireScope_AllowsWhenPresent(t *testing.T) { - key := &datastores.ApiKeyResult{ - Scopes: map[string]struct{}{"read:tickets": {}}, - } - ctx := ContextWithKey(context.Background(), key) - err := RequireScope(ctx, "read:tickets") - assert.NoError(t, err) -} - -func TestRequireScope_RejectsWhenMissing(t *testing.T) { - key := &datastores.ApiKeyResult{ - Scopes: map[string]struct{}{"read": {}}, - } - ctx := ContextWithKey(context.Background(), key) - err := RequireScope(ctx, "read:tickets") - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.PermissionDenied, st.Code()) -} - -func TestRequireScope_RejectsWhenKeyAbsent(t *testing.T) { - err := RequireScope(context.Background(), "read") - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.PermissionDenied, st.Code()) -} - -// buildAuthCtx returns an incoming-gRPC context populated with a bearer token -// and a peer address — the two things the interceptor reads off the wire. -func buildAuthCtx(token, peerIP string) context.Context { - ctx := metadata.NewIncomingContext( - context.Background(), - metadata.Pairs("authorization", "Bearer "+token), - ) - return peer.NewContext(ctx, &peer.Peer{ - Addr: &net.TCPAddr{IP: net.ParseIP(peerIP), Port: 4242}, - }) -} - -func TestAuthInterceptor_LogsRequestOnSuccess(t *testing.T) { - ds := &fakeDatastore{ - validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - return &datastores.ApiKeyResult{KeyId: 17}, nil - }, - } - buf := captureInfo(t) - interceptor := NewAuthInterceptor(ds) - ctx := buildAuthCtx("cav7_abc", "10.0.0.5") - info := &grpc.UnaryServerInfo{FullMethod: "/proto.MilpacService/GetProfile"} - resp, err := interceptor(ctx, "req", info, func(ctx context.Context, req any) (any, error) { - return "ok", nil - }) - require.NoError(t, err) - assert.Equal(t, "ok", resp) - - logged := buf.String() - assert.Contains(t, logged, "[REQ] transport=grpc") - assert.Contains(t, logged, "method=/proto.MilpacService/GetProfile") - assert.Contains(t, logged, "peer=10.0.0.5:4242") - assert.Contains(t, logged, "key_id=17") -} - -// rawAuthCtx sets the authorization metadata verbatim (no implicit "Bearer " -// prefix), so scheme-problem cases can be exercised. -func rawAuthCtx(authHeader, peerIP string) context.Context { - var ctx context.Context - if authHeader == "" { - ctx = context.Background() - } else { - ctx = metadata.NewIncomingContext( - context.Background(), - metadata.Pairs("authorization", authHeader), - ) - } - return peer.NewContext(ctx, &peer.Peer{ - Addr: &net.TCPAddr{IP: net.ParseIP(peerIP), Port: 4242}, - }) -} - -func runInterceptor(t *testing.T, ds *fakeDatastore, ctx context.Context) (any, error, bool) { - t.Helper() - interceptor := NewAuthInterceptor(ds) - info := &grpc.UnaryServerInfo{FullMethod: "/proto.MilpacService/GetProfile"} - handlerCalled := false - resp, err := interceptor(ctx, "req", info, func(ctx context.Context, req any) (any, error) { - handlerCalled = true - return "ok", nil - }) - return resp, err, handlerCalled -} - -func TestAuthInterceptor_NoAuthHeader_NamesBearerScheme(t *testing.T) { - ds := &fakeDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - t.Fatal("ValidateApiKey must not be called when no authorization metadata is present") - return nil, nil - }} - _, err, called := runInterceptor(t, ds, rawAuthCtx("", "10.0.0.5")) - - require.Error(t, err) - assert.False(t, called) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code()) - assert.Contains(t, st.Message(), "Bearer") - assert.Contains(t, st.Message(), "Authorization") -} - -func TestAuthInterceptor_RawKeyNoBearerPrefix_NamesBearerScheme(t *testing.T) { - ds := &fakeDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - t.Fatal("ValidateApiKey must not be called when the Bearer scheme is absent") - return nil, nil - }} - _, err, called := runInterceptor(t, ds, rawAuthCtx("cav7_rawkeynoprefix", "10.0.0.5")) - - require.Error(t, err) - assert.False(t, called) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code()) - assert.Contains(t, st.Message(), "Bearer") - assert.Contains(t, st.Message(), "Authorization") -} - -func TestAuthInterceptor_BadKey_GenericNoLeak(t *testing.T) { - ds := &fakeDatastore{validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - assert.Equal(t, "cav7_badkey", token) - return nil, nil - }} - _, err, called := runInterceptor(t, ds, buildAuthCtx("cav7_badkey", "10.0.0.5")) - - require.Error(t, err) - assert.False(t, called) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code()) - // Generic — must not name the Bearer scheme (that's reserved for scheme errors). - assert.NotContains(t, st.Message(), "Bearer") -} - -func TestAuthInterceptor_LogsRequestOnAuthFailure(t *testing.T) { - ds := &fakeDatastore{ - validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - return nil, status.Errorf(codes.Unauthenticated, "bad") - }, - } - buf := captureInfo(t) - interceptor := NewAuthInterceptor(ds) - ctx := buildAuthCtx("cav7_bogus", "10.0.0.5") - info := &grpc.UnaryServerInfo{FullMethod: "/proto.MilpacService/GetProfile"} - handlerCalled := false - _, err := interceptor(ctx, "req", info, func(ctx context.Context, req any) (any, error) { - handlerCalled = true - return nil, nil - }) - require.Error(t, err) - assert.False(t, handlerCalled, "handler must not run on auth failure") - - logged := buf.String() - assert.Contains(t, logged, "[REQ] transport=grpc") - assert.Contains(t, logged, "method=/proto.MilpacService/GetProfile") - assert.Contains(t, logged, "peer=10.0.0.5:4242") - assert.Contains(t, logged, "key_id=none") -} diff --git a/servers/grpc/authentication.go b/servers/grpc/authentication.go deleted file mode 100644 index 5d5bc75..0000000 --- a/servers/grpc/authentication.go +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package grpc - -import ( - "context" - "strconv" - "time" - - "github.com/7cav/api/datastores" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/peer" - "google.golang.org/grpc/status" -) - -const maxTokenLen = 128 - -// errBearerScheme mirrors the HTTP gateway's scheme-problem message: it names -// the expected Authorization format so callers missing the "Bearer " prefix -// get a self-explanatory error. Returned for any Bearer-scheme problem (no -// metadata, no authorization header, or an empty/oversized token). The -// key-validation-failure branch stays generic to leak nothing about the key. -const errBearerScheme = "Unauthenticated: expected 'Authorization: Bearer ' metadata" - -func NewAuthInterceptor(ds datastores.Datastore) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - // Phase 0 measuring stick (#112/#114): duration= times auth + handler - // (excludes gRPC response marshal/wire write). The handler is included - // because the deferred line fires after the `return handler(...)` - // value is computed. Temporary field; retires with this stack once - // Prometheus owns metrics. Appended after the existing fields so the - // ad-hoc log analytics keep parsing. - start := time.Now() - peerAddr := "unknown" - if p, ok := peer.FromContext(ctx); ok && p.Addr != nil { - peerAddr = p.Addr.String() - } - keyID := "none" - defer func() { - Info.Printf("[REQ] transport=grpc method=%s peer=%s key_id=%s duration=%v", info.FullMethod, peerAddr, keyID, time.Since(start)) - }() - - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, status.Error(codes.Unauthenticated, errBearerScheme) - } - - authHeaders := md.Get("authorization") - if len(authHeaders) < 1 { - return nil, status.Error(codes.Unauthenticated, errBearerScheme) - } - - token := datastores.ParseBearerToken(authHeaders[0], maxTokenLen) - if token == "" { - return nil, status.Error(codes.Unauthenticated, errBearerScheme) - } - - key, err := ds.ValidateApiKey(token) - if err != nil || key == nil { - return nil, status.Error(codes.Unauthenticated, "invalid api key") - } - - keyID = strconv.FormatUint(uint64(key.KeyId), 10) - return handler(ContextWithKey(ctx, key), req) - } -} diff --git a/servers/grpc/authentication_test.go b/servers/grpc/authentication_test.go deleted file mode 100644 index 5b2a9f7..0000000 --- a/servers/grpc/authentication_test.go +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package grpc - -import ( - "context" - "regexp" - "testing" - "time" - - "github.com/7cav/api/datastores" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" -) - -// reqDurationPattern extracts the duration= field from a [REQ] log line. -var reqDurationPattern = regexp.MustCompile(`duration=(\S+)`) - -// extractReqDuration pulls the duration= value out of the captured log output -// and parses it, so tests assert on a real time.Duration rather than string -// shape alone. -func extractReqDuration(t *testing.T, logged string) time.Duration { - t.Helper() - m := reqDurationPattern.FindStringSubmatch(logged) - require.Len(t, m, 2, "log output must carry a duration= field, got: %q", logged) - d, err := time.ParseDuration(m[1]) - require.NoError(t, err, "duration= value must parse as a Go duration") - return d -} - -// Phase 0 measuring stick (#114): the [REQ] line must time auth + handler -// (excludes gRPC response marshal/wire write), appended after the existing -// fields so the ad-hoc analytics keep parsing transport/method/peer/key_id -// unchanged. -func TestAuthInterceptor_ReqLineCarriesHandlerDuration(t *testing.T) { - ds := &fakeDatastore{ - validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - return &datastores.ApiKeyResult{KeyId: 17}, nil - }, - } - buf := captureInfo(t) - interceptor := NewAuthInterceptor(ds) - ctx := buildAuthCtx("cav7_abc", "10.0.0.5") - info := &grpc.UnaryServerInfo{FullMethod: "/proto.MilpacService/GetProfile"} - - const handlerDelay = 15 * time.Millisecond - resp, err := interceptor(ctx, "req", info, func(ctx context.Context, req any) (any, error) { - time.Sleep(handlerDelay) - return "ok", nil - }) - require.NoError(t, err) - assert.Equal(t, "ok", resp) - - logged := buf.String() - // Existing fields stay, in order; duration= is appended after key_id. - assert.Regexp(t, - `\[REQ\] transport=grpc method=/proto\.MilpacService/GetProfile peer=10\.0\.0\.5:4242 key_id=17 duration=\S+`, - logged) - d := extractReqDuration(t, logged) - assert.GreaterOrEqual(t, d, handlerDelay, - "duration must cover the handler, not just interceptor overhead") -} - -// Every [REQ] line carries duration= — including requests rejected before the -// handler runs (auth failures), where key_id stays "none". -func TestAuthInterceptor_ReqLineCarriesDurationOnAuthFailure(t *testing.T) { - ds := &fakeDatastore{ - validateApiKey: func(string) (*datastores.ApiKeyResult, error) { - return nil, nil - }, - } - buf := captureInfo(t) - interceptor := NewAuthInterceptor(ds) - ctx := buildAuthCtx("cav7_badkey", "10.0.0.5") - info := &grpc.UnaryServerInfo{FullMethod: "/proto.MilpacService/GetProfile"} - - _, err := interceptor(ctx, "req", info, func(ctx context.Context, req any) (any, error) { - t.Fatal("handler must not run on auth failure") - return nil, nil - }) - require.Error(t, err) - - logged := buf.String() - assert.Regexp(t, `\[REQ\] transport=grpc method=\S+ peer=\S+ key_id=none duration=\S+`, logged) - extractReqDuration(t, logged) -} diff --git a/servers/grpc/fake_datastore_test.go b/servers/grpc/fake_datastore_test.go deleted file mode 100644 index 8377b8e..0000000 --- a/servers/grpc/fake_datastore_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package grpc - -import ( - "context" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" -) - -// fakeDatastore is the shared in-process Datastore stub used by both -// tickets_test.go and milpacs_test.go. Each handler-under-test gets the -// matching function field populated; everything else either panics (methods -// this PR does not exercise) or nil-derefs on call (unset configurable -// fields) so an accidentally-untouched test fails loudly with a clear stack -// rather than a silent zero-value response. -type fakeDatastore struct { - // Tickets — configurable function fields used by tickets_test.go. - listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) - getTicket func(uint32) (*proto.Ticket, error) - getByRef func(string) (*proto.Ticket, error) - firstMsgs func(uint32, int) ([]*proto.Message, uint32, error) - listMsgs func(uint32, string, uint32, bool) ([]*proto.Message, string, bool, error) - listCats func() ([]*proto.Category, error) - - // Milpacs — configurable function fields. Unset fields nil-deref on call; - // same diagnostic level as tickets fields. - findProfilesById func(...uint64) ([]*proto.Profile, error) - findProfilesByUsername func(string) ([]*proto.Profile, error) - findProfileByKeycloakID func(string) (*proto.Profile, error) - findProfileByDiscordID func(string) (*proto.Profile, error) - findProfileByGamertag func(string) (*proto.Profile, error) - findRosterByType func(proto.RosterType) (*proto.Roster, error) - findLiteRosterByType func(proto.RosterType) (*proto.LiteRoster, error) - findS1UniformsRosterByType func(proto.RosterType) (*proto.S1UniformsRoster, error) - - // Auth — used by auth_test.go to drive the interceptor end-to-end. - validateApiKey func(string) (*datastores.ApiKeyResult, error) -} - -func (f *fakeDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, fi *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { - return f.listTickets(fi) -} -func (f *fakeDatastore) GetTicket(_ context.Context, _ datastores.TicketReferenceCache, id uint32, _ string) (*proto.Ticket, error) { - return f.getTicket(id) -} -func (f *fakeDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketReferenceCache, ref string, _ string) (*proto.Ticket, error) { - return f.getByRef(ref) -} -func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, id uint32, n int, _ bool) ([]*proto.Message, uint32, error) { - return f.firstMsgs(id, n) -} -func (f *fakeDatastore) ListTicketMessages(_ context.Context, id uint32, after string, per uint32, hidden bool) ([]*proto.Message, string, bool, error) { - return f.listMsgs(id, after, per, hidden) -} -func (f *fakeDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*proto.Category, error) { - return f.listCats() -} - -func (f *fakeDatastore) FindProfilesById(ids ...uint64) ([]*proto.Profile, error) { - return f.findProfilesById(ids...) -} -func (f *fakeDatastore) FindProfilesByUsername(u string) ([]*proto.Profile, error) { - return f.findProfilesByUsername(u) -} -func (f *fakeDatastore) FindRosterByType(t proto.RosterType) (*proto.Roster, error) { - return f.findRosterByType(t) -} -func (f *fakeDatastore) FindLiteRosterByType(t proto.RosterType) (*proto.LiteRoster, error) { - return f.findLiteRosterByType(t) -} -func (f *fakeDatastore) FindProfileByKeycloakID(k string) (*proto.Profile, error) { - return f.findProfileByKeycloakID(k) -} -func (f *fakeDatastore) FindProfileByDiscordID(d string) (*proto.Profile, error) { - return f.findProfileByDiscordID(d) -} -func (f *fakeDatastore) FindS1UniformsRosterByType(t proto.RosterType) (*proto.S1UniformsRoster, error) { - return f.findS1UniformsRosterByType(t) -} -func (f *fakeDatastore) FindProfileByGamertag(g string) (*proto.Profile, error) { - return f.findProfileByGamertag(g) -} - -// Milpacs methods these tests do not exercise — stay panicking. -func (f *fakeDatastore) FindProfilesByPosition(string) (*proto.LiteRoster, error) { panic("unused") } -func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { panic("unused") } -func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { panic("unused") } -func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { panic("unused") } - -func (f *fakeDatastore) ValidateApiKey(token string) (*datastores.ApiKeyResult, error) { - return f.validateApiKey(token) -} - -// makeKeyCtx builds a context carrying an ApiKeyResult with the given scope -// set. The withTicketsKey / withMilpacsKey wrappers stay as named entry -// points so a test reader sees which handler family the scope applies to. -func makeKeyCtx(scopes ...string) context.Context { - m := map[string]struct{}{} - for _, s := range scopes { - m[s] = struct{}{} - } - return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) -} - -// withTicketsKey builds a context carrying an ApiKeyResult with the given -// scope set. Mirrors the auth middleware's behavior in production. -func withTicketsKey(scopes ...string) context.Context { return makeKeyCtx(scopes...) } - -// withMilpacsKey is the milpacs-handler equivalent of withTicketsKey. -// Pass "read" to satisfy RequireScope; pass nothing for permission-denied paths. -func withMilpacsKey(scopes ...string) context.Context { return makeKeyCtx(scopes...) } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go deleted file mode 100644 index 4b9f6f0..0000000 --- a/servers/grpc/grpc.go +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package grpc - -import ( - "context" - "errors" - "log" - "os" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" - "gorm.io/gorm" -) - -type MilpacsService struct { - Datastore datastores.Datastore -} - -var ( - Info = log.New(os.Stdout, "INFO: ", log.LstdFlags) - Warn = log.New(os.Stdout, "WARNING: ", log.LstdFlags) - Error = log.New(os.Stdout, "ERROR: ", log.LstdFlags) -) - -func (server *MilpacsService) GetProfile(ctx context.Context, request *proto.ProfileRequest) (*proto.Profile, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - var ( - profiles []*proto.Profile - err error - ) - if request.Username != "" { - Info.Println("GetProfile, Requested via username") - profiles, err = server.Datastore.FindProfilesByUsername(request.Username) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "no profile found for username: %s", request.Username) - } - return nil, status.Errorf(codes.Internal, "fetch profile by username: %v", err) - } - } else if request.UserId != 0 { - Info.Println("GetProfile, requested via userid") - profiles, err = server.Datastore.FindProfilesById(request.UserId) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "no profile found for user ID: %d", request.UserId) - } - return nil, status.Errorf(codes.Internal, "fetch profile by user id: %v", err) - } - } else { - return nil, status.Errorf(codes.InvalidArgument, "no username or user ID provided") - } - - return profiles[0], nil -} - -func (server *MilpacsService) GetRoster(ctx context.Context, request *proto.RosterRequest) (*proto.Roster, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - if request.Roster == proto.RosterType_ROSTER_TYPE_UNSPECIFIED { - return nil, status.Errorf(codes.InvalidArgument, "cannot request null roster type") - } - - roster, err := server.Datastore.FindRosterByType(request.Roster) - - if err != nil { - return nil, status.Errorf(codes.Internal, "fetch roster %s: %v", request.Roster, err) - } - - return roster, nil -} - -func (server *MilpacsService) GetUserViaKeycloakId(ctx context.Context, request *proto.KeycloakIdRequest) (*proto.Profile, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - - if request.GetKeycloakId() == "" { - return nil, status.Errorf(codes.InvalidArgument, "keycloak id cannot be empty") - } - - profile, err := server.Datastore.FindProfileByKeycloakID(request.GetKeycloakId()) - - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "no user found for keycloakid: %s", request.GetKeycloakId()) - } - return nil, status.Errorf(codes.Internal, "fetch profile by keycloak id: %v", err) - } - - return profile, nil -} - -func (server *MilpacsService) GetUserViaDiscordId(ctx context.Context, request *proto.DiscordIdRequest) (*proto.Profile, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - - if request.GetDiscordId() == "" { - return nil, status.Errorf(codes.InvalidArgument, "discord id cannot be empty") - } - - profile, err := server.Datastore.FindProfileByDiscordID(request.GetDiscordId()) - - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "no user found for discordid: %s", request.GetDiscordId()) - } - return nil, status.Errorf(codes.Internal, "fetch profile by discord id: %v", err) - } - - return profile, nil -} -func (server *MilpacsService) GetLiteRoster(ctx context.Context, request *proto.RosterRequest) (*proto.LiteRoster, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - if request.Roster == proto.RosterType_ROSTER_TYPE_UNSPECIFIED { - return nil, status.Errorf(codes.InvalidArgument, "cannot request null roster type") - } - - roster, err := server.Datastore.FindLiteRosterByType(request.Roster) - - if err != nil { - return nil, status.Errorf(codes.Internal, "fetch lite roster %s: %v", request.Roster, err) - } - - return roster, nil -} -func (server *MilpacsService) SearchByPosition(ctx context.Context, request *proto.PositionSearchRequest) (*proto.LiteRoster, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - if request.GetPositionQuery() == "" { - return nil, status.Errorf(codes.InvalidArgument, "position query cannot be empty") - } - - roster, err := server.Datastore.FindProfilesByPosition(request.GetPositionQuery()) - if err != nil { - return nil, status.Errorf(codes.Internal, "error searching profiles by position: %v", err) - } - - return roster, nil -} - -func (server *MilpacsService) GetS1UniformsRoster(ctx context.Context, request *proto.RosterRequest) (*proto.S1UniformsRoster, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - if request.Roster == proto.RosterType_ROSTER_TYPE_UNSPECIFIED { - return nil, status.Errorf(codes.InvalidArgument, "cannot request null roster type") - } - - roster, err := server.Datastore.FindS1UniformsRosterByType(request.Roster) - - if err != nil { - return nil, status.Errorf(codes.Internal, "fetch s1 uniforms roster %s: %v", request.Roster, err) - } - return roster, nil -} - -func (server *MilpacsService) GetAllRanks(ctx context.Context, _ *emptypb.Empty) (*proto.RanksResponse, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - ranks, err := server.Datastore.FindAllRanks() - if err != nil { - return nil, status.Errorf(codes.Internal, "error fetching ranks: %v", err) - } - - return &proto.RanksResponse{ - Ranks: ranks, - }, nil -} - -func (server *MilpacsService) GetPositionGroups(ctx context.Context, _ *emptypb.Empty) (*proto.PositionGroupsResponse, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - groups, err := server.Datastore.FindAllPositionGroups() - if err != nil { - return nil, status.Errorf(codes.Internal, "error fetching position groups: %v", err) - } - - return &proto.PositionGroupsResponse{ - Groups: groups, - }, nil -} - -func (server *MilpacsService) GetAwol(ctx context.Context, _ *emptypb.Empty) (*proto.AwolResponse, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - awols, err := server.Datastore.FindAwol() - if err != nil { - return nil, status.Errorf(codes.Internal, "error fetching AWOL list: %v", err) - } - - return &proto.AwolResponse{ - Awols: awols, - }, nil -} - -func (server *MilpacsService) GetGamertagProfile(ctx context.Context, request *proto.GamertagRequest) (*proto.Profile, error) { - if err := RequireScope(ctx, "read"); err != nil { - return nil, err - } - if request.GetGamertag() == "" { - Warn.Println("Empty Gamertag provided, cannot return profile") - return nil, status.Errorf(codes.InvalidArgument, "gamertag cannot be empty") - } - - profile, err := server.Datastore.FindProfileByGamertag(request.GetGamertag()) - - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "no user found for gamertag: %v", request.GetGamertag()) - } - return nil, status.Errorf(codes.Internal, "fetch profile by gamertag: %v", err) - } - return profile, nil -} diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go deleted file mode 100644 index 6c1c489..0000000 --- a/servers/grpc/milpacs_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package grpc - -import ( - "errors" - "testing" - - "github.com/7cav/api/proto" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "gorm.io/gorm" -) - -func TestGetProfile_ByUsername_NotFound(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfilesByUsername: func(string) ([]*proto.Profile, error) { - return nil, gorm.ErrRecordNotFound - }, - }} - _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{Username: "ghost"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.NotFound, st.Code()) -} - -func TestGetProfile_ByUsername_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfilesByUsername: func(string) ([]*proto.Profile, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{Username: "anyone"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetProfile_ByUserID_NotFound(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfilesById: func(...uint64) ([]*proto.Profile, error) { - return nil, gorm.ErrRecordNotFound - }, - }} - _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{UserId: 99999}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.NotFound, st.Code()) -} - -func TestGetProfile_ByUserID_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfilesById: func(...uint64) ([]*proto.Profile, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{UserId: 42}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetUserViaKeycloakId_NotFound(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfileByKeycloakID: func(string) (*proto.Profile, error) { - return nil, gorm.ErrRecordNotFound - }, - }} - _, err := svc.GetUserViaKeycloakId(withMilpacsKey("read"), &proto.KeycloakIdRequest{KeycloakId: "ghost-uuid"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.NotFound, st.Code()) -} - -func TestGetUserViaKeycloakId_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfileByKeycloakID: func(string) (*proto.Profile, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetUserViaKeycloakId(withMilpacsKey("read"), &proto.KeycloakIdRequest{KeycloakId: "any"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetUserViaDiscordId_NotFound(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfileByDiscordID: func(string) (*proto.Profile, error) { - return nil, gorm.ErrRecordNotFound - }, - }} - _, err := svc.GetUserViaDiscordId(withMilpacsKey("read"), &proto.DiscordIdRequest{DiscordId: "404"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.NotFound, st.Code()) -} - -func TestGetUserViaDiscordId_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfileByDiscordID: func(string) (*proto.Profile, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetUserViaDiscordId(withMilpacsKey("read"), &proto.DiscordIdRequest{DiscordId: "any"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetGamertagProfile_NotFound(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfileByGamertag: func(string) (*proto.Profile, error) { - return nil, gorm.ErrRecordNotFound - }, - }} - _, err := svc.GetGamertagProfile(withMilpacsKey("read"), &proto.GamertagRequest{Gamertag: "Nobody#0001"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.NotFound, st.Code()) -} - -func TestGetGamertagProfile_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findProfileByGamertag: func(string) (*proto.Profile, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetGamertagProfile(withMilpacsKey("read"), &proto.GamertagRequest{Gamertag: "any"}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetUserViaKeycloakId_EmptyID_InvalidArgument(t *testing.T) { - // findProfileByKeycloakID left unset: a fall-through to the datastore - // nil-derefs and panics, proving the handler early-returns instead. - svc := &MilpacsService{Datastore: &fakeDatastore{}} - _, err := svc.GetUserViaKeycloakId(withMilpacsKey("read"), &proto.KeycloakIdRequest{KeycloakId: ""}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestGetUserViaDiscordId_EmptyID_InvalidArgument(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{}} - _, err := svc.GetUserViaDiscordId(withMilpacsKey("read"), &proto.DiscordIdRequest{DiscordId: ""}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestGetGamertagProfile_Empty_InvalidArgument(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{}} - _, err := svc.GetGamertagProfile(withMilpacsKey("read"), &proto.GamertagRequest{Gamertag: ""}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestGetRoster_NullType_InvalidArgument(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{}} - _, err := svc.GetRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_UNSPECIFIED}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestGetLiteRoster_NullType_InvalidArgument(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{}} - _, err := svc.GetLiteRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_UNSPECIFIED}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestGetS1UniformsRoster_NullType_InvalidArgument(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{}} - _, err := svc.GetS1UniformsRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_UNSPECIFIED}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestGetRoster_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findRosterByType: func(proto.RosterType) (*proto.Roster, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_COMBAT}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetLiteRoster_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findLiteRosterByType: func(proto.RosterType) (*proto.LiteRoster, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetLiteRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_COMBAT}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} - -func TestGetS1UniformsRoster_DatastoreError(t *testing.T) { - svc := &MilpacsService{Datastore: &fakeDatastore{ - findS1UniformsRosterByType: func(proto.RosterType) (*proto.S1UniformsRoster, error) { - return nil, errors.New("boom") - }, - }} - _, err := svc.GetS1UniformsRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_COMBAT}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Internal, st.Code()) -} diff --git a/servers/grpc/sentry.go b/servers/grpc/sentry.go deleted file mode 100644 index 63ee3c4..0000000 --- a/servers/grpc/sentry.go +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package grpc - -import ( - "context" - "strconv" - "time" - - "github.com/getsentry/sentry-go" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// sentryFlushTimeout bounds how long a panicking request waits for its event -// to reach Sentry before the panic resumes (and, for gRPC, the process dies). -const sentryFlushTimeout = 2 * time.Second - -// NewSentryInterceptor reports handler panics and Internal-class errors to -// Sentry, tagged with the route and the calling API key id (never the bearer -// token). Expects to run inside auth so the key is already on ctx; if it is -// absent, the event simply omits the key_id tag. -// -// An Internal-class error on an HTTP-originated request produces two events — -// this gRPC exception plus the gateway's HTTP message, sharing the key_id but -// with distinct transport tags. Deliberate Phase 0 wrap-both-ends behavior. -// -// Without an initialised Sentry client (no SENTRY_DSN) it is a pass-through: -// errors flow unchanged and panics propagate exactly as they do today. -// Panics are re-raised after capture either way — Phase 0 observes, it does -// not change crash semantics (PRD #112). -func NewSentryInterceptor() grpc.UnaryServerInterceptor { - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { - if sentry.CurrentHub().Client() == nil { - return handler(ctx, req) - } - - hub := sentry.CurrentHub().Clone() - scope := hub.Scope() - scope.SetTag("transport", "grpc") - scope.SetTag("route", info.FullMethod) - if key := KeyFromContext(ctx); key != nil { - scope.SetTag("key_id", strconv.FormatUint(uint64(key.KeyId), 10)) - } - - defer func() { - if r := recover(); r != nil { - hub.RecoverWithContext(ctx, r) // event queued; ID unused — async transport - // The panic will take the process down (grpc-go has no - // recovery layer) — flush synchronously so the event - // survives the crash. - if !hub.Flush(sentryFlushTimeout) { - Warn.Printf("sentry flush timed out — panic event for %s was likely dropped", info.FullMethod) - } - panic(r) - } - }() - - resp, err = handler(ctx, req) - if err != nil { - if code := status.Code(err); isServerErrorCode(code) { - scope.SetTag("grpc_code", code.String()) - // status.Error values carry no stack, so the SDK stamps every - // capture here with the identical interceptor-frame stack — - // default grouping would fold all Internal-class errors across - // all methods into ONE Sentry issue. Group by code+method - // instead (mirrors the HTTP middleware's method+status - // fingerprint). Panic events are untouched: they carry real - // stacks and group fine on default rules. - scope.SetFingerprint([]string{"grpc-error", code.String(), info.FullMethod}) - hub.CaptureException(err) // event queued; ID unused — async transport - } - } - return resp, err - } -} - -// isServerErrorCode reports whether a gRPC status code is Internal-class — -// i.e. it maps to an HTTP 5xx under the grpc-gateway translation. Client-side -// codes (NotFound, Unauthenticated, InvalidArgument, ...) are expected -// behavior, not errors worth a Sentry event. (Unrecognized custom codes, -// which the gateway also maps to 500, are deliberately ignored here.) -func isServerErrorCode(code codes.Code) bool { - switch code { - case codes.Unknown, codes.DeadlineExceeded, codes.Unimplemented, - codes.Internal, codes.Unavailable, codes.DataLoss: - return true - default: - return false - } -} diff --git a/servers/grpc/sentry_test.go b/servers/grpc/sentry_test.go deleted file mode 100644 index a1bbe58..0000000 --- a/servers/grpc/sentry_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package grpc - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os" - "sync" - "testing" - "time" - - "github.com/7cav/api/datastores" - "github.com/getsentry/sentry-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -// captureTransport is an in-memory sentry.Transport recording every event the -// client would have sent over the wire — the observable seam for these tests. -// drainTimesOut makes Flush report a drain timeout (queue still busy when the -// window closes), NOT a delivery failure — per the SDK's Flush contract, fast -// send failures dequeue and "flush" successfully. flushed records that a -// synchronous flush happened at all (pinning the sync-flush-on-panic -// contract). -type captureTransport struct { - mu sync.Mutex - events []*sentry.Event - flushed bool - drainTimesOut bool -} - -func (t *captureTransport) Configure(sentry.ClientOptions) {} - -func (t *captureTransport) Flush(time.Duration) bool { - t.mu.Lock() - defer t.mu.Unlock() - t.flushed = true - return !t.drainTimesOut -} - -func (t *captureTransport) FlushWithContext(context.Context) bool { - t.mu.Lock() - defer t.mu.Unlock() - t.flushed = true - return !t.drainTimesOut -} - -func (t *captureTransport) Close() {} - -func (t *captureTransport) Flushed() bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.flushed -} - -func (t *captureTransport) SendEvent(event *sentry.Event) { - t.mu.Lock() - defer t.mu.Unlock() - t.events = append(t.events, event) -} - -func (t *captureTransport) Events() []*sentry.Event { - t.mu.Lock() - defer t.mu.Unlock() - return append([]*sentry.Event(nil), t.events...) -} - -// testRelease stands in for the build-time version injection so tests can -// prove events carry the release tag. -const testRelease = "test-release-1.2.3" - -// bindCaptureClient binds a capture-only Sentry client to the global hub for -// the duration of the test, restoring the unbound (disabled) state afterwards. -// AttachStacktrace mirrors production (setupSentry) so string-panic events -// carry frames here exactly as they do live. -func bindCaptureClient(t *testing.T) *captureTransport { - t.Helper() - transport := &captureTransport{} - client, err := sentry.NewClient(sentry.ClientOptions{Transport: transport, Release: testRelease, AttachStacktrace: true}) - require.NoError(t, err) - sentry.CurrentHub().BindClient(client) - t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) - return transport -} - -func unaryInfo(method string) *grpc.UnaryServerInfo { - return &grpc.UnaryServerInfo{FullMethod: method} -} - -func TestSentryInterceptor_InternalError_CapturedWithRouteAndKeyID(t *testing.T) { - transport := bindCaptureClient(t) - interceptor := NewSentryInterceptor() - - ctx := ContextWithKey(context.Background(), &datastores.ApiKeyResult{KeyId: 42}) - handlerErr := status.Error(codes.Internal, "boom") - resp, err := interceptor(ctx, nil, unaryInfo("/proto.MilpacService/GetProfile"), - func(ctx context.Context, req any) (any, error) { - return nil, handlerErr - }) - - assert.Nil(t, resp) - assert.Equal(t, handlerErr, err, "interceptor must pass the handler error through unchanged") - - events := transport.Events() - require.Len(t, events, 1, "an Internal-class error must produce exactly one event") - event := events[0] - assert.Equal(t, "/proto.MilpacService/GetProfile", event.Tags["route"]) - assert.Equal(t, "42", event.Tags["key_id"]) - assert.Equal(t, "grpc", event.Tags["transport"]) - assert.Equal(t, "Internal", event.Tags["grpc_code"]) -} - -func TestSentryInterceptor_ClientClassOutcomes_NoEvents(t *testing.T) { - transport := bindCaptureClient(t) - interceptor := NewSentryInterceptor() - ctx := context.Background() - info := unaryInfo("/proto.MilpacService/GetProfile") - - cases := []struct { - name string - err error - }{ - {"success", nil}, - {"not found", status.Error(codes.NotFound, "no such profile")}, - {"unauthenticated", status.Error(codes.Unauthenticated, "invalid api key")}, - {"invalid argument", status.Error(codes.InvalidArgument, "bad cursor")}, - {"permission denied", status.Error(codes.PermissionDenied, "scope required")}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resp, err := interceptor(ctx, nil, info, - func(ctx context.Context, req any) (any, error) { - if tc.err != nil { - return nil, tc.err - } - return "ok", nil - }) - assert.Equal(t, tc.err, err) - if tc.err == nil { - assert.Equal(t, "ok", resp) - } - }) - } - - assert.Empty(t, transport.Events(), "expected outcomes must not generate Sentry noise") -} - -// TestSentryInterceptor_CapturedErrorClass pins exactly which gRPC outcomes -// are Internal-class (captured) versus expected behavior (silent). A plain -// non-status error surfaces as codes.Unknown — the most common real-world -// server bug shape — and must be captured. -func TestSentryInterceptor_CapturedErrorClass(t *testing.T) { - cases := []struct { - name string - err error - captured bool - wantCode string - }{ - {"plain error becomes Unknown — captured", errors.New("kapow"), true, "Unknown"}, - {"unavailable — captured", status.Error(codes.Unavailable, "downstream gone"), true, "Unavailable"}, - {"deadline exceeded — captured", status.Error(codes.DeadlineExceeded, "too slow"), true, "DeadlineExceeded"}, - {"aborted — silent", status.Error(codes.Aborted, "tx conflict"), false, ""}, - {"resource exhausted — silent", status.Error(codes.ResourceExhausted, "rate limited"), false, ""}, - {"not found — silent", status.Error(codes.NotFound, "no such row"), false, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - transport := bindCaptureClient(t) - interceptor := NewSentryInterceptor() - - _, err := interceptor(context.Background(), nil, unaryInfo("/proto.MilpacService/GetProfile"), - func(ctx context.Context, req any) (any, error) { - return nil, tc.err - }) - - assert.Equal(t, tc.err, err, "the handler error must pass through unchanged") - events := transport.Events() - if !tc.captured { - assert.Empty(t, events, "expected outcomes must not generate Sentry noise") - return - } - require.Len(t, events, 1, "an Internal-class outcome must produce exactly one event") - assert.Equal(t, tc.wantCode, events[0].Tags["grpc_code"]) - assert.Equal(t, []string{"grpc-error", tc.wantCode, "/proto.MilpacService/GetProfile"}, events[0].Fingerprint, - "status errors carry no stack — without an explicit code+method fingerprint the SDK stamps every capture with the same interceptor frames and folds all Internal-class errors into one issue") - }) - } -} - -// TestSentryInterceptor_BehindAuthInterceptor_EventCarriesKeyID composes the -// real auth interceptor around the real Sentry interceptor — mirroring the -// grpc.ChainUnaryInterceptor order in server.go — and proves the key auth -// attaches to ctx is what feeds the key_id tag. No test-injected key. -func TestSentryInterceptor_BehindAuthInterceptor_EventCarriesKeyID(t *testing.T) { - transport := bindCaptureClient(t) - ds := &fakeDatastore{validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - assert.Equal(t, "cav7_goodkey", token) - return &datastores.ApiKeyResult{KeyId: 42}, nil - }} - authInterceptor := NewAuthInterceptor(ds) - sentryInterceptor := NewSentryInterceptor() - info := unaryInfo("/proto.MilpacService/GetProfile") - - ctx := metadata.NewIncomingContext(context.Background(), - metadata.Pairs("authorization", "Bearer cav7_goodkey")) - handlerErr := status.Error(codes.Internal, "boom") - resp, err := authInterceptor(ctx, nil, info, func(ctx context.Context, req any) (any, error) { - return sentryInterceptor(ctx, req, info, func(ctx context.Context, req any) (any, error) { - return nil, handlerErr - }) - }) - - assert.Nil(t, resp) - assert.Equal(t, handlerErr, err) - events := transport.Events() - require.Len(t, events, 1) - assert.Equal(t, "42", events[0].Tags["key_id"], - "the key_id must come from auth's ctx attachment, proving the chain wiring end-to-end") - assert.Equal(t, "grpc", events[0].Tags["transport"]) -} - -func TestSentryInterceptor_HandlerPanic_CapturedThenRepanics(t *testing.T) { - transport := bindCaptureClient(t) - interceptor := NewSentryInterceptor() - ctx := ContextWithKey(context.Background(), &datastores.ApiKeyResult{KeyId: 7}) - - assert.PanicsWithValue(t, "kaboom", func() { - _, _ = interceptor(ctx, nil, unaryInfo("/proto.TicketsService/ListTickets"), - func(ctx context.Context, req any) (any, error) { - panic("kaboom") - }) - }, "panic must propagate after capture — Phase 0 observes, it does not change crash semantics") - - events := transport.Events() - require.Len(t, events, 1, "a handler panic must produce exactly one event") - event := events[0] - assert.Equal(t, "/proto.TicketsService/ListTickets", event.Tags["route"]) - assert.Equal(t, "7", event.Tags["key_id"]) - assert.Equal(t, "grpc", event.Tags["transport"]) - assert.Equal(t, sentry.LevelFatal, event.Level) - assert.Equal(t, testRelease, event.Release, "panic events must carry the release") - assert.True(t, transport.Flushed(), - "panic events must be flushed synchronously — the process is about to die and the async transport with it") - require.NotEmpty(t, event.Threads, "a string panic must carry a stack (AttachStacktrace shapes it as a thread)") - require.NotNil(t, event.Threads[0].Stacktrace) - assert.NotEmpty(t, event.Threads[0].Stacktrace.Frames, "a string panic without frames is undebuggable") -} - -func TestSentryInterceptor_PanicFlushTimeout_Warns(t *testing.T) { - transport := bindCaptureClient(t) - transport.drainTimesOut = true - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - interceptor := NewSentryInterceptor() - assert.PanicsWithValue(t, "kaboom", func() { - _, _ = interceptor(context.Background(), nil, unaryInfo("/proto.TicketsService/ListTickets"), - func(ctx context.Context, req any) (any, error) { - panic("kaboom") - }) - }, "a failed flush must not block the re-panic") - - assert.Contains(t, warnBuf.String(), "panic event for /proto.TicketsService/ListTickets was likely dropped", - "a dropped crash event must at least leave a trace in the process logs") -} - -func TestSentryInterceptor_NoClient_PassThrough(t *testing.T) { - // No client bound — the no-DSN state. Errors and panics must behave - // exactly as they do today. - require.Nil(t, sentry.CurrentHub().Client(), "test requires the disabled state") - interceptor := NewSentryInterceptor() - info := unaryInfo("/proto.MilpacService/GetProfile") - - handlerErr := status.Error(codes.Internal, "boom") - resp, err := interceptor(context.Background(), nil, info, - func(ctx context.Context, req any) (any, error) { - return nil, handlerErr - }) - assert.Nil(t, resp) - assert.Equal(t, handlerErr, err) - - assert.PanicsWithValue(t, "kaboom", func() { - _, _ = interceptor(context.Background(), nil, info, - func(ctx context.Context, req any) (any, error) { - panic("kaboom") - }) - }, "panics must still propagate when Sentry is disabled") -} - -func TestSentryInterceptor_BearerTokenNeverInPayload(t *testing.T) { - transport := bindCaptureClient(t) - interceptor := NewSentryInterceptor() - - // Simulate the real request shape: the bearer token sits in the incoming - // gRPC metadata on ctx, exactly where the auth interceptor read it. The - // query-shaped gateway URI pair extends the whole-payload sweep to the - // query-borne vector (?api_key=...) arriving through forwarded metadata. - const secret = "cav7_topsecrettokenvalue" - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs( - "authorization", "Bearer "+secret, - "grpcgateway-uri", "/api/v1/roster?api_key="+secret, - )) - ctx = ContextWithKey(ctx, &datastores.ApiKeyResult{KeyId: 42}) - - _, _ = interceptor(ctx, nil, unaryInfo("/proto.MilpacService/GetProfile"), - func(ctx context.Context, req any) (any, error) { - return nil, status.Error(codes.Internal, "boom") - }) - assert.PanicsWithValue(t, "kaboom", func() { - _, _ = interceptor(ctx, nil, unaryInfo("/proto.MilpacService/GetProfile"), - func(ctx context.Context, req any) (any, error) { - panic("kaboom") - }) - }) - - events := transport.Events() - require.Len(t, events, 2) - for _, event := range events { - payload, err := json.Marshal(event) - require.NoError(t, err) - assert.NotContains(t, string(payload), secret, "bearer material must never appear in any event payload") - assert.Equal(t, "42", event.Tags["key_id"], "events carry the key id, not the key") - } -} diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go deleted file mode 100644 index 4740f63..0000000 --- a/servers/grpc/tickets.go +++ /dev/null @@ -1,128 +0,0 @@ -package grpc - -import ( - "context" - "errors" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" - "github.com/7cav/api/referencecache" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" - "gorm.io/gorm" -) - -// TicketsService implements proto.TicketsServiceServer. -type TicketsService struct { - proto.UnimplementedTicketsServiceServer - - Datastore datastores.Datastore - ReferenceCache referencecache.ReferenceCache -} - -func (s *TicketsService) ListTickets(ctx context.Context, req *proto.ListTicketsRequest) (*proto.ListTicketsResponse, error) { - if err := RequireScope(ctx, "read:tickets"); err != nil { - return nil, err - } - filter := &datastores.ListTicketsFilter{ - CategoryIDs: req.CategoryId, - ExcludeSubcategories: req.ExcludeSubcategories, - TicketStates: req.TicketState, - StatusIDs: req.StatusId, - PrefixIDs: req.PrefixId, - AssignedUserIDs: req.AssignedUserId, - StarterUserIDs: req.StarterUserId, - ModifiedSince: req.ModifiedSince, - IncludeHidden: req.IncludeHidden, - PerPage: req.PerPage, - AfterCursor: req.AfterCursor, - } - tickets, next, more, err := s.Datastore.ListTickets(ctx, s.ReferenceCache, filter) - if err != nil { - if errors.Is(err, datastores.ErrInvalidCursor) { - return nil, status.Errorf(codes.InvalidArgument, "invalid after_cursor") - } - return nil, status.Errorf(codes.Internal, "list tickets: %v", err) - } - return &proto.ListTicketsResponse{ - Tickets: tickets, - NextCursor: next, - HasMore: more, - }, nil -} - -const firstMessagesCount = 10 - -func (s *TicketsService) GetTicket(ctx context.Context, req *proto.GetTicketRequest) (*proto.GetTicketResponse, error) { - if err := RequireScope(ctx, "read:tickets"); err != nil { - return nil, err - } - ticket, err := s.Datastore.GetTicket(ctx, s.ReferenceCache, req.TicketId, "") - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "ticket %d not found", req.TicketId) - } - return nil, status.Errorf(codes.Internal, "fetch ticket: %v", err) - } - msgs, total, err := s.Datastore.GetTicketFirstMessages(ctx, ticket.TicketId, firstMessagesCount, false) - if err != nil { - return nil, status.Errorf(codes.Internal, "fetch ticket messages: %v", err) - } - return &proto.GetTicketResponse{ - Ticket: ticket, - FirstMessages: msgs, - TotalMessageCount: total, - }, nil -} - -func (s *TicketsService) GetTicketByRef(ctx context.Context, req *proto.GetTicketByRefRequest) (*proto.GetTicketResponse, error) { - if err := RequireScope(ctx, "read:tickets"); err != nil { - return nil, err - } - ticket, err := s.Datastore.GetTicketByRef(ctx, s.ReferenceCache, req.TicketRef, "") - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(codes.NotFound, "ticket %q not found", req.TicketRef) - } - return nil, status.Errorf(codes.Internal, "fetch ticket: %v", err) - } - msgs, total, err := s.Datastore.GetTicketFirstMessages(ctx, ticket.TicketId, firstMessagesCount, false) - if err != nil { - return nil, status.Errorf(codes.Internal, "fetch ticket messages: %v", err) - } - return &proto.GetTicketResponse{ - Ticket: ticket, - FirstMessages: msgs, - TotalMessageCount: total, - }, nil -} - -func (s *TicketsService) ListTicketMessages(ctx context.Context, req *proto.ListTicketMessagesRequest) (*proto.ListTicketMessagesResponse, error) { - if err := RequireScope(ctx, "read:tickets"); err != nil { - return nil, err - } - msgs, next, more, err := s.Datastore.ListTicketMessages(ctx, req.TicketId, req.AfterCursor, req.PerPage, req.IncludeHidden) - if err != nil { - if errors.Is(err, datastores.ErrInvalidCursor) { - return nil, status.Errorf(codes.InvalidArgument, "invalid after_cursor") - } - return nil, status.Errorf(codes.Internal, "list ticket messages: %v", err) - } - return &proto.ListTicketMessagesResponse{ - Messages: msgs, - NextCursor: next, - HasMore: more, - }, nil -} - -func (s *TicketsService) ListCategories(ctx context.Context, _ *emptypb.Empty) (*proto.ListCategoriesResponse, error) { - if err := RequireScope(ctx, "read:tickets"); err != nil { - return nil, err - } - cats, err := s.Datastore.ListCategories(ctx, s.ReferenceCache) - if err != nil { - return nil, status.Errorf(codes.Internal, "list ticket categories: %v", err) - } - return &proto.ListCategoriesResponse{Categories: cats}, nil -} diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go deleted file mode 100644 index 6b2c3e7..0000000 --- a/servers/grpc/tickets_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package grpc - -import ( - "errors" - "fmt" - "testing" - - "github.com/7cav/api/datastores" - "github.com/7cav/api/proto" - "github.com/7cav/api/referencecache" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" - "gorm.io/gorm" -) - -func TestListTickets_RequiresScope(t *testing.T) { - svc := &TicketsService{Datastore: &fakeDatastore{}, ReferenceCache: &referencecache.Cache{}} - ctx := withTicketsKey() // no scopes - _, err := svc.ListTickets(ctx, &proto.ListTicketsRequest{}) - require.Error(t, err) - st, _ := status.FromError(err) - assert.Equal(t, codes.PermissionDenied, st.Code()) -} - -func TestListTickets_HappyPath(t *testing.T) { - var got *datastores.ListTicketsFilter - svc := &TicketsService{ - Datastore: &fakeDatastore{ - listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { - got = f - return []*proto.Ticket{{TicketId: 1}}, "next123", true, nil - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - ctx := withTicketsKey("read:tickets") - req := &proto.ListTicketsRequest{ - CategoryId: []uint32{5}, - ExcludeSubcategories: true, - TicketState: []string{"open"}, - StatusId: []uint32{1, 3}, - PrefixId: []uint32{2}, - AssignedUserId: []uint32{100}, - StarterUserId: []uint32{200}, - ModifiedSince: 1736000000, - IncludeHidden: true, - PerPage: 25, - AfterCursor: "cursor-abc", - } - resp, err := svc.ListTickets(ctx, req) - require.NoError(t, err) - require.NotNil(t, got) - - wantFilter := &datastores.ListTicketsFilter{ - CategoryIDs: []uint32{5}, - ExcludeSubcategories: true, - TicketStates: []string{"open"}, - StatusIDs: []uint32{1, 3}, - PrefixIDs: []uint32{2}, - AssignedUserIDs: []uint32{100}, - StarterUserIDs: []uint32{200}, - ModifiedSince: 1736000000, - IncludeHidden: true, - PerPage: 25, - AfterCursor: "cursor-abc", - } - assert.Equal(t, wantFilter, got) - - require.Len(t, resp.Tickets, 1) - assert.Equal(t, "next123", resp.NextCursor) - assert.True(t, resp.HasMore) -} - -func TestGetTicket_NotFoundReturns404Code(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - getTicket: func(id uint32) (*proto.Ticket, error) { - return nil, gorm.ErrRecordNotFound - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - _, err := svc.GetTicket(withTicketsKey("read:tickets"), &proto.GetTicketRequest{TicketId: 99999}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.NotFound, st.Code()) -} - -func TestListTickets_DatastoreError(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - listTickets: func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { - return nil, "", false, errors.New("boom") - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - _, err := svc.ListTickets(withTicketsKey("read:tickets"), &proto.ListTicketsRequest{}) - require.Error(t, err) -} - -func TestGetTicket_PopulatesFirstMessages(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - getTicket: func(id uint32) (*proto.Ticket, error) { - assert.Equal(t, uint32(7499), id) - return &proto.Ticket{TicketId: 7499, TicketRef: "MF1UI9HE"}, nil - }, - firstMsgs: func(id uint32, n int) ([]*proto.Message, uint32, error) { - assert.Equal(t, uint32(7499), id) - assert.Equal(t, 10, n) - return []*proto.Message{{MessageId: 1}, {MessageId: 2}}, 15, nil - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - resp, err := svc.GetTicket(withTicketsKey("read:tickets"), &proto.GetTicketRequest{TicketId: 7499}) - require.NoError(t, err) - assert.Equal(t, "MF1UI9HE", resp.Ticket.TicketRef) - assert.Len(t, resp.FirstMessages, 2) - assert.Equal(t, uint32(15), resp.TotalMessageCount) -} - -func TestGetTicket_RequiresScope(t *testing.T) { - svc := &TicketsService{Datastore: &fakeDatastore{}, ReferenceCache: &referencecache.Cache{}} - _, err := svc.GetTicket(withTicketsKey(), &proto.GetTicketRequest{TicketId: 1}) - require.Error(t, err) -} - -func TestGetTicketByRef_HappyPath(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - getByRef: func(ref string) (*proto.Ticket, error) { - assert.Equal(t, "MF1UI9HE", ref) - return &proto.Ticket{TicketId: 1}, nil - }, - firstMsgs: func(uint32, int) ([]*proto.Message, uint32, error) { return nil, 0, nil }, - }, - ReferenceCache: &referencecache.Cache{}, - } - resp, err := svc.GetTicketByRef(withTicketsKey("read:tickets"), &proto.GetTicketByRefRequest{TicketRef: "MF1UI9HE"}) - require.NoError(t, err) - assert.Equal(t, uint32(1), resp.Ticket.TicketId) -} - -func TestListTicketMessages_HappyPath(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - listMsgs: func(id uint32, after string, per uint32, hidden bool) ([]*proto.Message, string, bool, error) { - assert.Equal(t, uint32(7499), id) - assert.Equal(t, "Y3Vyc29yOjEw", after, "handler must pass cursor through unchanged") - assert.Equal(t, uint32(50), per) - assert.False(t, hidden) - return []*proto.Message{{MessageId: 11}}, "Y3Vyc29yOjEx", true, nil - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - resp, err := svc.ListTicketMessages(withTicketsKey("read:tickets"), &proto.ListTicketMessagesRequest{ - TicketId: 7499, AfterCursor: "Y3Vyc29yOjEw", PerPage: 50, - }) - require.NoError(t, err) - require.Len(t, resp.Messages, 1) - assert.Equal(t, "Y3Vyc29yOjEx", resp.NextCursor) - assert.True(t, resp.HasMore) -} - -func TestListCategories_HappyPath(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - listCats: func() ([]*proto.Category, error) { - return []*proto.Category{{CategoryId: 5, Title: "S1"}}, nil - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - resp, err := svc.ListCategories(withTicketsKey("read:tickets"), &emptypb.Empty{}) - require.NoError(t, err) - require.Len(t, resp.Categories, 1) - assert.Equal(t, "S1", resp.Categories[0].Title) -} - -func TestListTickets_InvalidCursorMapsTo400(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - listTickets: func(_ *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { - // Simulate datastore returning a wrapped ErrInvalidCursor. - return nil, "", false, fmt.Errorf("decode: %w", datastores.ErrInvalidCursor) - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - _, err := svc.ListTickets(withTicketsKey("read:tickets"), &proto.ListTicketsRequest{ - AfterCursor: "garbage", - }) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "expected gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) -} - -func TestListTicketMessages_InvalidCursorMapsTo400(t *testing.T) { - svc := &TicketsService{ - Datastore: &fakeDatastore{ - listMsgs: func(_ uint32, _ string, _ uint32, _ bool) ([]*proto.Message, string, bool, error) { - return nil, "", false, fmt.Errorf("decode: %w", datastores.ErrInvalidCursor) - }, - }, - ReferenceCache: &referencecache.Cache{}, - } - _, err := svc.ListTicketMessages(withTicketsKey("read:tickets"), &proto.ListTicketMessagesRequest{ - TicketId: 7499, - AfterCursor: "garbage", - }) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "expected gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) -} diff --git a/servers/sentry.go b/servers/sentry.go deleted file mode 100644 index 4e92cc2..0000000 --- a/servers/sentry.go +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright (C) 2021 7Cav.us - * This file is part of 7Cav-API . - * - * 7Cav-API is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * 7Cav-API is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with 7Cav-API. If not, see . - */ - -package servers - -import ( - "net" - "os" - "os/signal" - "strconv" - "strings" - "syscall" - "time" - - "github.com/getsentry/sentry-go" - "github.com/spf13/viper" -) - -// sentryShutdownFlushTimeout bounds how long shutdown waits for buffered -// events to reach Sentry before the process exits. -const sentryShutdownFlushTimeout = 2 * time.Second - -// sentryStartupProbeTimeout bounds the boot-time drain check. A var, not a -// const, only so tests can shrink the window; production never mutates it. -var sentryStartupProbeTimeout = 5 * time.Second - -// sentryDialCheckTimeout bounds the boot-time TCP reachability pre-check of -// the DSN ingest host — generous enough for a cold DNS resolve plus a -// cross-region handshake, small enough to keep the worst-case boot delay -// acceptable (it stacks with the probe window before any listener opens). A -// var, not a const, only so tests can shrink the window; production never -// mutates it. -var sentryDialCheckTimeout = 3 * time.Second - -// sentryTransportOverride, when non-nil, replaces the transport of the client -// setupSentry constructs. Test-only injection seam (#179): the probe-stall -// test needs a transport whose Flush deterministically reports not-drained, -// and binding a stub onto the hub cannot reach the client setupSentry binds -// itself. Nil in production — the SDK builds its real HTTP transport and -// nothing here changes. Caveat: a non-nil transport flips sentry-go (v0.46.2) -// into its legacy-transport client mode (with batchMeter), whereas -// production's nil selects the telemetry-processor mode — stub-transport tests -// therefore exercise the legacy flush path; re-check on SDK bumps. -var sentryTransportOverride sentry.Transport - -// setupSentry initialises Sentry error capture (errors only, no tracing) when -// SENTRY_DSN is present in the environment. Without a DSN nothing is -// initialised — no client, no capture, local/dev unaffected; the only side -// effect is one Info line making the disabled state visible at boot. Returns -// whether capture was enabled. -// -// Temporary Phase 0 scaffolding (PRD #112): the rewritten stack gets its own -// first-class wiring in Phase 3. -func setupSentry() bool { - dsn := viper.GetString("SENTRY_DSN") - if dsn == "" { - Info.Println("Sentry disabled — SENTRY_DSN not set") - return false - } - - err := sentry.Init(sentry.ClientOptions{ - Dsn: dsn, - // Release reuses the build-time version injection - // (-X github.com/7cav/api/servers.version=). - Release: version, - // Errors only — no tracing (PRD #112 Phase 0). - EnableTracing: false, - // Explicit zero: the SDK normalises 0 to its default of 1.0, so - // every error event is sent (confirmed convention in #113). - SampleRate: 0, - // String panics (panic("msg")) become message events; without this - // they would arrive with no stack trace at all. Side effect: HTTP 5xx - // CaptureMessage events also gain a (middleware-frame) Threads stack — - // grouping is unaffected because they set an explicit fingerprint. - AttachStacktrace: true, - // Env-gated SDK diagnostics: send failures, drops and rate limits - // otherwise go to a debug logger defaulting to io.Discard. One log - // line per event when on — too chatty for always-on, but lets ops - // diagnose delivery without a rebuild. - Debug: sentryDebugEnabled(), - // SDK debug lines land on the Warn logger's underlying writer with - // the SDK's own "[Sentry] " prefix — NOT the app's "WARNING: " - // prefix. Log scrapers keyed on our prefixes will not match them. - DebugWriter: Warn.Writer(), - // Belt-and-braces scrubbing at the choke point — see scrubEvent for - // the exact (request-material-only) scope of the guarantee. - BeforeSend: scrubEvent, - // Nil in production (SDK default transport); see - // sentryTransportOverride for the test-only seam. - Transport: sentryTransportOverride, - }) - if err != nil { - // Telemetry must never take the API down: log and run without it. - Warn.Printf("sentry init failed — continuing without error capture: %v", err) - return false - } - - // Warn-only reachability pre-check: catches the misconfig class the probe - // below structurally cannot (fast send failures drain the queue and so - // still "flush"). Never changes the enabled/degraded semantics. - sentryDialCheck(dsn) - - // Probe failure still returns true — enabled-degraded, not disabled: a - // slow-network false positive must not turn off capture. Do NOT refactor - // this into `return sentryStartupProbe()`. - if sentryStartupProbe() { - Info.Println("Sentry error capture enabled (errors only), release:", version, - "— startup probe flushed (queue drained; delivery not verified — set SENTRY_DEBUG=true to confirm)") - } - return true -} - -// sentryDebugEnabled reads SENTRY_DEBUG strictly. viper.GetBool silently maps -// unparseable values ("yes", "on", typos) to false — a mid-incident operator -// trap: debug looks enabled but nothing logs. Unset stays silently off; a set -// but unparseable value warns and is treated as off. -func sentryDebugEnabled() bool { - raw := viper.GetString("SENTRY_DEBUG") - if raw == "" { - return false - } - enabled, err := strconv.ParseBool(raw) - if err != nil { - Warn.Printf("SENTRY_DEBUG=%q is not a boolean (use \"true\" or \"false\") — treating as off", raw) - return false - } - return enabled -} - -// sentryDialCheck is a boot-time, warn-only TCP reachability check of the DSN -// ingest host. It exists because the startup probe cannot see fast send -// failures — DNS errors and refused connections drain the transport queue in -// milliseconds, so the probe's flush still reports success (see -// sentryStartupProbe). A failed dial here names the unreachable host while -// the probe would stay silent. Warn-only by design: the network may heal, and -// a boot-time blip must not disable capture. -func sentryDialCheck(rawDSN string) { - dsn, err := sentry.NewDsn(rawDSN) - if err != nil { - // Unreachable in practice — sentry.Init already parsed this DSN. - return - } - addr := net.JoinHostPort(dsn.GetHost(), strconv.Itoa(dsn.GetPort())) - conn, err := net.DialTimeout("tcp", addr, sentryDialCheckTimeout) - if err != nil { - Warn.Printf("sentry DSN host %s is unreachable (%v) — error events will not be delivered; set SENTRY_DEBUG=true for per-event transport diagnostics", addr, err) - return - } - _ = conn.Close() -} - -// sentryStartupProbe pushes one canary event through the real transport and -// flushes. sentry.Init does no network I/O, so without this the pipeline is -// first exercised by the first real error. -// -// What a true return PROVES — per the SDK's documented Flush contract (queue -// drained, NOT delivered): the transport finished its send attempts within -// the window. That catches hang-class failures (blackholed egress, connects -// slower than the window) and guarantees one event exercised the full -// pipeline so SENTRY_DEBUG has something to report. What it does NOT prove: -// delivery. The transport worker dequeues on ANY send outcome, so DNS -// failures, refused connections, Sentry-side rejections (bad DSN key → 4xx) -// and rate-limit drops all complete in milliseconds, drain the queue, and -// "flush" successfully — those classes are visible only with -// SENTRY_DEBUG=true (and partially via sentryDialCheck). -func sentryStartupProbe() bool { - // Event hygiene: a fixed fingerprint + info level fold every container - // restart into one low-severity Sentry issue instead of resolve→reopen - // churn; the probe tag makes canaries filterable. Cloned hub so none of - // this leaks into the global scope. - var id *sentry.EventID - hub := sentry.CurrentHub().Clone() - hub.WithScope(func(scope *sentry.Scope) { - scope.SetFingerprint([]string{"sentry-startup-probe"}) - scope.SetTag("probe", "true") - scope.SetLevel(sentry.LevelInfo) - id = hub.CaptureMessage("sentry startup probe") - }) - if id == nil { - // Nil event ID = the client dropped the event before the transport - // saw it (e.g. a BeforeSend veto) — nothing queued, so a "successful" - // flush below would be vacuous. - Warn.Println("sentry startup probe was dropped client-side — no canary reached the transport (check BeforeSend/sampling)") - return false - } - if !hub.Flush(sentryStartupProbeTimeout) { - Warn.Println("sentry enabled but startup probe did not flush — transport still busy after the window; events may not be reaching Sentry (check DSN/egress, or set SENTRY_DEBUG=true)") - return false - } - return true -} - -// flushSentryOnShutdown installs a signal handler that flushes buffered -// Sentry events before the process exits. The current stack has no graceful -// shutdown path (Start blocks on Serve and the process dies by signal); this -// is the minimal hook so error events from the final moments are not lost. -// Only installed when Sentry is enabled, so the no-DSN path keeps today's -// default signal behavior exactly — guarded here as well as at the call site, -// because installing the handler without a client would silently rewrite the -// process's signal semantics for nothing. -func flushSentryOnShutdown() { - if sentry.CurrentHub().Client() == nil { - // Only reachable on programmer error — the call site gates on - // setupSentry(). Loud so misuse never silently skips the handler. - Warn.Println("flushSentryOnShutdown called without an initialised sentry client — shutdown flush handler not installed") - return - } - signals := make(chan os.Signal, 1) - signal.Notify(signals, os.Interrupt, syscall.SIGTERM) - go watchShutdown(signals, - func() bool { return sentry.Flush(sentryShutdownFlushTimeout) }, - os.Exit, - ) -} - -// watchShutdown waits for a shutdown signal, flushes, then exits 0. Split -// from flushSentryOnShutdown so the flush-before-exit ordering is testable. -// Note on os.Exit: deferred functions do not run, and a second signal -// arriving during the flush window is absorbed by the still-installed -// handler — the process is already on its way down. -func watchShutdown(signals <-chan os.Signal, flush func() bool, exit func(code int)) { - sig := <-signals - Info.Printf("received %v — flushing sentry before exit", sig) - if !flush() { - // "Remaining", not "all": events sent before the window closed made - // it out — only what was still buffered is gone. - Warn.Println("sentry shutdown flush window expired — remaining buffered events lost") - } - exit(0) -} - -// scrubEvent is the BeforeSend hook closing the request-material vector: it -// strips authorization / proxy-authorization / cookie headers -// (case-insensitive), the cookie jar, and the raw query string from every -// outgoing error event. It does NOT scan exception text, breadcrumbs or -// extras — never put key material in error strings. If tracing is ever -// enabled, transactions bypass BeforeSend and need an equivalent -// BeforeSendTransaction hook. -func scrubEvent(event *sentry.Event, _ *sentry.EventHint) *sentry.Event { - if event.Request == nil { - return event - } - event.Request.Cookies = "" - event.Request.QueryString = "" - for name := range event.Request.Headers { - switch strings.ToLower(name) { - case "authorization", "cookie", "proxy-authorization": - delete(event.Request.Headers, name) - } - } - return event -} diff --git a/servers/sentry_test.go b/servers/sentry_test.go deleted file mode 100644 index af96787..0000000 --- a/servers/sentry_test.go +++ /dev/null @@ -1,488 +0,0 @@ -package servers - -import ( - "bytes" - "context" - "encoding/json" - "net" - "os" - "sync" - "syscall" - "testing" - "time" - - "github.com/7cav/api/datastores" - "github.com/getsentry/sentry-go" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -// stubTransport is an in-memory sentry.Transport with a controllable Flush -// outcome. flushDrains=false simulates a drain timeout (queue still busy when -// the window closes — the hang-class failure mode), NOT a delivery failure: -// per the SDK's Flush contract, fast send failures dequeue and "flush" -// successfully. -type stubTransport struct { - mu sync.Mutex - events []*sentry.Event - flushDrains bool -} - -func (t *stubTransport) Configure(sentry.ClientOptions) {} -func (t *stubTransport) Flush(time.Duration) bool { return t.flushDrains } -func (t *stubTransport) FlushWithContext(context.Context) bool { return t.flushDrains } -func (t *stubTransport) Close() {} - -func (t *stubTransport) SendEvent(event *sentry.Event) { - t.mu.Lock() - defer t.mu.Unlock() - t.events = append(t.events, event) -} - -func (t *stubTransport) Events() []*sentry.Event { - t.mu.Lock() - defer t.mu.Unlock() - return append([]*sentry.Event(nil), t.events...) -} - -// bindStubClient binds a stub-transport Sentry client to the global hub for -// the duration of the test, restoring the unbound (disabled) state afterwards. -func bindStubClient(t *testing.T, flushDrains bool) *stubTransport { - t.Helper() - transport := &stubTransport{flushDrains: flushDrains} - client, err := sentry.NewClient(sentry.ClientOptions{Transport: transport}) - require.NoError(t, err) - sentry.CurrentHub().BindClient(client) - t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) - return transport -} - -// setSentryTransportOverride points the sentryTransportOverride seam (#179) at -// tr for the duration of the test, restoring the production nil afterwards. -// The reset rides along with the set, so a set-without-reset is unwritable -// through this helper. -func setSentryTransportOverride(t *testing.T, tr sentry.Transport) { - t.Helper() - sentryTransportOverride = tr - t.Cleanup(func() { sentryTransportOverride = nil }) -} - -func TestSetupSentry_NoDSN_Disabled(t *testing.T) { - viper.Set("SENTRY_DSN", "") - defer viper.Set("SENTRY_DSN", "") - - var infoBuf bytes.Buffer - Info.SetOutput(&infoBuf) - defer Info.SetOutput(os.Stdout) - - enabled := setupSentry() - - assert.False(t, enabled, "setupSentry must be a no-op when SENTRY_DSN is unset") - assert.Nil(t, sentry.CurrentHub().Client(), "no client may be bound without a DSN") - assert.Contains(t, infoBuf.String(), "Sentry disabled — SENTRY_DSN not set", - "the disabled state must be visible at boot, not silent") -} - -func TestSetupSentry_MalformedDSN_DisabledWithoutPanic(t *testing.T) { - viper.Set("SENTRY_DSN", "not-a-dsn") - defer viper.Set("SENTRY_DSN", "") - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - enabled := setupSentry() - - assert.False(t, enabled, "a malformed DSN must disable capture, never take the API down") - assert.Nil(t, sentry.CurrentHub().Client(), "no client may be bound after a failed init") - assert.Contains(t, warnBuf.String(), "sentry init failed — continuing without error capture", - "a config so broken it disables capture must say so in the logs, not just return false") -} - -func TestSetupSentry_WithDSN_InitialisesErrorsOnlyClientWithRelease(t *testing.T) { - viper.Set("SENTRY_DSN", "https://public@example.ingest.sentry.io/1") - viper.Set("SENTRY_DEBUG", true) - defer func() { - viper.Set("SENTRY_DSN", "") - viper.Set("SENTRY_DEBUG", false) - sentry.CurrentHub().BindClient(nil) - }() - - // Shrink the startup-probe flush and dial-check windows: the example DSN - // is not routable, so neither boot check may stall the suite for seconds. - restoreProbe := sentryStartupProbeTimeout - sentryStartupProbeTimeout = 50 * time.Millisecond - defer func() { sentryStartupProbeTimeout = restoreProbe }() - restoreDial := sentryDialCheckTimeout - sentryDialCheckTimeout = 50 * time.Millisecond - defer func() { sentryDialCheckTimeout = restoreDial }() - - enabled := setupSentry() - - assert.True(t, enabled) - client := sentry.CurrentHub().Client() - require.NotNil(t, client, "a client must be bound when SENTRY_DSN is set") - opts := client.Options() - assert.Equal(t, version, opts.Release, "release must reuse the build-time version injection") - assert.False(t, opts.EnableTracing, "errors only — no tracing") - assert.Equal(t, 1.0, opts.SampleRate, "explicit zero must normalise to the SDK default of 1.0 — every error event is sent") - assert.True(t, opts.AttachStacktrace, "string panics must carry stack traces") - assert.True(t, opts.Debug, "SENTRY_DEBUG=true must enable SDK diagnostics without a rebuild") - assert.NotNil(t, opts.DebugWriter, "SDK diagnostics must land in our logs, not the default stderr") - - // Don't trust NotNil — drive the WIRED hook with credential-bearing - // request material and prove it scrubs. Any function would satisfy - // NotNil; only the real scrubber survives this. - require.NotNil(t, opts.BeforeSend, "scrubber must be wired so bearer material can never leave the process") - fixture := sentry.NewEvent() - fixture.Request = &sentry.Request{ - Headers: map[string]string{ - "Authorization": "Bearer cav7_wiredsecret", - "Cookie": "session=wired123", - }, - Cookies: "session=wired123", - QueryString: "api_key=cav7_wiredsecret", - } - scrubbed := opts.BeforeSend(fixture, nil) - require.NotNil(t, scrubbed, "the wired hook must sanitise, never drop, the event") - payload, err := json.Marshal(scrubbed) - require.NoError(t, err) - assert.NotContains(t, string(payload), "cav7_wiredsecret", - "the wired hook must strip bearer material from headers and query strings") - assert.NotContains(t, string(payload), "session=wired123", - "the wired hook must strip cookie material from headers and the cookie jar") -} - -func TestSentryStartupProbe_DrainTimeout_WarnsLoudly(t *testing.T) { - bindStubClient(t, false) - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - ok := sentryStartupProbe() - - assert.False(t, ok) - assert.Contains(t, warnBuf.String(), "startup probe did not flush", - "a hang-class transport stall is the one failure mode the drain probe can see — it must be loud") -} - -func TestSentryStartupProbe_FlushDrains_NoWarning(t *testing.T) { - transport := bindStubClient(t, true) - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - ok := sentryStartupProbe() - - assert.True(t, ok) - assert.Empty(t, warnBuf.String(), "a drained probe must not warn") - events := transport.Events() - require.Len(t, events, 1, "the probe must push exactly one canary event through the transport") - event := events[0] - assert.Equal(t, "sentry startup probe", event.Message) - assert.Equal(t, []string{"sentry-startup-probe"}, event.Fingerprint, - "a fixed fingerprint folds every container restart into one Sentry issue — no resolve→reopen churn") - assert.Equal(t, "true", event.Tags["probe"], "probe events must be filterable") - assert.Equal(t, sentry.LevelInfo, event.Level, "the canary is informational, never an alertable error") -} - -func TestSentryStartupProbe_ClientSideDrop_WarnsAndFails(t *testing.T) { - // A BeforeSend veto makes CaptureMessage return a nil event ID — the - // client dropped the canary before the transport ever saw it, so there is - // nothing to drain and the flush alone would report a false success. - client, err := sentry.NewClient(sentry.ClientOptions{ - Transport: &stubTransport{flushDrains: true}, - BeforeSend: func(*sentry.Event, *sentry.EventHint) *sentry.Event { return nil }, - }) - require.NoError(t, err) - sentry.CurrentHub().BindClient(client) - t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - ok := sentryStartupProbe() - - assert.False(t, ok, "a client-side drop means no canary exercised the pipeline — probe failed") - assert.Contains(t, warnBuf.String(), "dropped client-side", - "a canary that never reached the transport must be visible, not a silent flush success") -} - -// TestSetupSentry_ProbeStall_WarnsAndWithholdsEnabledLine pins that -// setupSentry actually INVOKES the startup probe against the client it just -// configured: a stub transport injected through the test seam reports -// Flush=not-drained, so the stalled-transport premise holds by construction — -// no socket dial manufactures the stall, and machine load cannot invert the -// outcome (#179: a refused dial is a fast send outcome that drains the queue, -// so the old local-server stall lost the timing race under load). Binding a -// stub onto the hub is not enough here — setupSentry binds its own client, so -// the seam must reach the client it constructs. Deleting the -// sentryStartupProbe() call from setupSentry turns this red (no stall -// warning, the enabled line prints, no canary reaches the transport). -func TestSetupSentry_ProbeStall_WarnsAndWithholdsEnabledLine(t *testing.T) { - transport := &stubTransport{flushDrains: false} - setSentryTransportOverride(t, transport) - - // 127.0.0.1:1 refuses instantly — the suite's deterministic stand-in for - // the dial-check pre-check, which is frozen and irrelevant to the stall - // premise. Shrink its window anyway so a pathological environment cannot - // stall the suite. The probe window itself no longer needs shrinking: the - // stub's Flush reports not-drained immediately, regardless of load. - viper.Set("SENTRY_DSN", "http://public@127.0.0.1:1/1") - defer func() { - viper.Set("SENTRY_DSN", "") - sentry.CurrentHub().BindClient(nil) - }() - restoreDial := sentryDialCheckTimeout - sentryDialCheckTimeout = 500 * time.Millisecond - defer func() { sentryDialCheckTimeout = restoreDial }() - - var warnBuf, infoBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - Info.SetOutput(&infoBuf) - defer Info.SetOutput(os.Stdout) - - enabled := setupSentry() - - assert.True(t, enabled, "a stalled probe means degraded, never disabled — capture stays on") - assert.Contains(t, warnBuf.String(), "startup probe did not flush", - "a transport that cannot drain within the window must be loud at boot") - assert.NotContains(t, infoBuf.String(), "Sentry error capture enabled", - "the success line must be withheld when the probe could not drain") - events := transport.Events() - require.Len(t, events, 1, - "the canary must reach the transport of the client setupSentry just configured — the probe ran against THAT client, not some pre-bound stub") - assert.Equal(t, "sentry startup probe", events[0].Message) -} - -// TestSentryDebugEnabled_StrictParse pins the operator-trap fix: viper's -// GetBool silently maps "yes"/"on"/typos to false, which mid-incident reads -// as "debug on but nothing logged". Unparseable values must warn. -func TestSentryDebugEnabled_StrictParse(t *testing.T) { - cases := []struct { - name string - raw string - want bool - wantWarn bool - }{ - {"unset — silently off", "", false, false}, - {"true", "true", true, false}, - {"1", "1", true, false}, - {"TRUE", "TRUE", true, false}, - {"false", "false", false, false}, - {"yes — operator trap, warn", "yes", false, true}, - {"on — operator trap, warn", "on", false, true}, - {"typo — warn", "ture", false, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - viper.Set("SENTRY_DEBUG", tc.raw) - defer viper.Set("SENTRY_DEBUG", "") - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - assert.Equal(t, tc.want, sentryDebugEnabled()) - if tc.wantWarn { - assert.Contains(t, warnBuf.String(), "SENTRY_DEBUG", - "an unparseable value must be called out by name, not silently treated as off") - assert.Contains(t, warnBuf.String(), tc.raw, "the warning must echo the rejected value") - } else { - assert.Empty(t, warnBuf.String(), "parseable or unset values must not warn") - } - }) - } -} - -func TestSentryDialCheck_UnreachableHost_WarnsWithHost(t *testing.T) { - // 127.0.0.1:1 refuses instantly — the deterministic stand-in for the - // wrong-DSN-host misconfig class the startup probe cannot see (fast send - // failures still drain the queue). Shrink the dial window anyway so a - // pathological environment cannot stall the suite. - restore := sentryDialCheckTimeout - sentryDialCheckTimeout = 500 * time.Millisecond - defer func() { sentryDialCheckTimeout = restore }() - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - sentryDialCheck("https://public@127.0.0.1:1/1") - - assert.Contains(t, warnBuf.String(), "127.0.0.1:1", - "the warning must name the unreachable host so the misconfig is actionable") - assert.Contains(t, warnBuf.String(), "SENTRY_DEBUG", - "the warning must point at the diagnostic that shows per-event send failures") -} - -func TestSentryDialCheck_ReachableHost_Silent(t *testing.T) { - lis, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer func() { _ = lis.Close() }() - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - sentryDialCheck("https://public@" + lis.Addr().String() + "/1") - - assert.Empty(t, warnBuf.String(), "a reachable ingest host must not warn") -} - -// chainFakeDatastore embeds the Datastore interface so it satisfies the type -// while implementing only ValidateApiKey — the single method the auth -// interceptor touches. -type chainFakeDatastore struct { - datastores.Datastore - validateApiKey func(string) (*datastores.ApiKeyResult, error) -} - -func (f *chainFakeDatastore) ValidateApiKey(rawKey string) (*datastores.ApiKeyResult, error) { - return f.validateApiKey(rawKey) -} - -// TestAPIUnaryInterceptors_AuthOuterSentryInner_KeyIDReachesEvent pins the -// gRPC interceptor chain ORDER as a tested contract — the mirror of the -// buildAPIHandler tests on the HTTP side. Auth must run outermost (attaching -// the validated key to ctx) and sentry inner (reading it for the key_id tag); -// flipping the pair loses the tag. -func TestAPIUnaryInterceptors_AuthOuterSentryInner_KeyIDReachesEvent(t *testing.T) { - transport := bindStubClient(t, true) - ds := &chainFakeDatastore{validateApiKey: func(token string) (*datastores.ApiKeyResult, error) { - assert.Equal(t, "cav7_goodkey", token) - return &datastores.ApiKeyResult{KeyId: 42}, nil - }} - - interceptors := apiUnaryInterceptors(ds) - require.Len(t, interceptors, 2, "the chain is exactly auth + sentry") - info := &grpc.UnaryServerInfo{FullMethod: "/proto.MilpacService/GetProfile"} - // Mimic grpc.ChainUnaryInterceptor semantics: index 0 is outermost. - chained := func(ctx context.Context, req any, handler grpc.UnaryHandler) (any, error) { - return interceptors[0](ctx, req, info, func(ctx context.Context, req any) (any, error) { - return interceptors[1](ctx, req, info, handler) - }) - } - - ctx := metadata.NewIncomingContext(context.Background(), - metadata.Pairs("authorization", "Bearer cav7_goodkey")) - handlerErr := status.Error(codes.Internal, "boom") - _, err := chained(ctx, nil, func(ctx context.Context, req any) (any, error) { - return nil, handlerErr - }) - - assert.Equal(t, handlerErr, err, "the handler error must pass through the whole chain unchanged") - events := transport.Events() - require.Len(t, events, 1, "one Internal error through the chain must mean exactly one event") - assert.Equal(t, "42", events[0].Tags["key_id"], - "auth must run outermost and attach the key before sentry reads it — an order flip silently loses key_id") -} - -func TestScrubEvent_StripsBearerAndCookieMaterial(t *testing.T) { - event := sentry.NewEvent() - event.Request = &sentry.Request{ - Method: "GET", - URL: "/api/v1/roster", - Headers: map[string]string{ - "Authorization": "Bearer cav7_supersecrettoken", - "cookie": "session=abc123", - "User-Agent": "test-agent", - }, - Cookies: "session=abc123", - QueryString: "api_key=cav7_supersecrettoken&page=2", - } - - scrubbed := scrubEvent(event, nil) - - require.NotNil(t, scrubbed, "scrubbing must sanitise, never drop, the event") - assert.Empty(t, scrubbed.Request.QueryString, "query strings can carry credentials — cleared wholesale") - payload, err := json.Marshal(scrubbed) - require.NoError(t, err) - assert.NotContains(t, string(payload), "cav7_supersecrettoken", "bearer material must never appear in any event payload") - assert.NotContains(t, string(payload), "session=abc123", "cookie material must never appear in any event payload") - assert.Contains(t, string(payload), "test-agent", "non-sensitive headers stay for debugging value") -} - -func TestScrubEvent_NoRequest_PassesThrough(t *testing.T) { - event := sentry.NewEvent() - event.Message = "plain event" - - scrubbed := scrubEvent(event, nil) - - require.NotNil(t, scrubbed) - assert.Equal(t, "plain event", scrubbed.Message) -} - -// TestFlushSentryOnShutdown_NoClient_WarnsAndSkips pins the guard's -// visibility: it only fires on programmer error (the call site must gate on -// setupSentry()), and silent misuse would mean shutdown flushes everyone -// assumes exist were never installed. -func TestFlushSentryOnShutdown_NoClient_WarnsAndSkips(t *testing.T) { - require.Nil(t, sentry.CurrentHub().Client(), "test requires the disabled state") - - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - flushSentryOnShutdown() - - assert.Contains(t, warnBuf.String(), "without an initialised sentry client", - "misuse must be visible in logs, not a silent no-op") -} - -func TestWatchShutdown_FlushesBeforeExit(t *testing.T) { - cases := []struct { - name string - flushOK bool - wantWarn bool - }{ - {"flush succeeds — silent", true, false}, - {"flush times out — dropped events are visible", false, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var warnBuf bytes.Buffer - Warn.SetOutput(&warnBuf) - defer Warn.SetOutput(os.Stdout) - - signals := make(chan os.Signal, 1) - done := make(chan struct{}) - var order []string - - go watchShutdown(signals, - func() bool { - order = append(order, "flush") - return tc.flushOK - }, - func(code int) { - assert.Equal(t, 0, code, "graceful shutdown must exit 0") - order = append(order, "exit") - close(done) - }) - - signals <- syscall.SIGTERM - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("watchShutdown never exited after the signal") - } - assert.Equal(t, []string{"flush", "exit"}, order, "buffered events must be flushed before the process exits") - if tc.wantWarn { - assert.Contains(t, warnBuf.String(), "shutdown flush window expired — remaining buffered events lost", - "silently dropped events at shutdown must leave a trace in the logs") - } else { - assert.NotContains(t, warnBuf.String(), "flush window expired", "a clean flush must not warn") - } - }) - } -} diff --git a/servers/server.go b/servers/server.go index a8758a9..726c9dc 100644 --- a/servers/server.go +++ b/servers/server.go @@ -21,47 +21,46 @@ package servers import ( "context" "fmt" - "io" "log" "net" "net/http" "os" + "strings" "time" "github.com/7cav/api/datastores" - milpacs "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" "github.com/7cav/api/rest" - httpServices "github.com/7cav/api/servers/gateway" - grpcServices "github.com/7cav/api/servers/grpc" "github.com/spf13/viper" - "google.golang.org/grpc" - "google.golang.org/grpc/grpclog" "gorm.io/driver/mysql" "gorm.io/gorm" ) // version is overridden at build time via -ldflags // "-X github.com/7cav/api/servers.version=" in the release workflow. -// Local dev builds report "dev". +// Local dev builds report "dev". It stamps the Sentry release and the served +// OpenAPI spec's info.version. var version = "dev" +// Public and internal listen addresses. The public listener (:11000) is the +// one nginx fronts; the metrics listener (:9090) is internal-only (kept off +// the published ports / firewalled at the compose+nginx layer, not in code). +const ( + publicAddr = "0.0.0.0:11000" + metricsAddr = "0.0.0.0:9090" +) + type MicroServer struct { - addr string - httpServer *http.Server - grpcServer *grpc.Server + publicServer *http.Server + metricsServer *http.Server referenceCache *referencecache.Cache } -// New initializes a new Backend struct. -// addr is the gRPC dial target consumed by the HTTP gateway (from $PORT). -// Listen ports for both gRPC (:10000) and HTTP (:11000) are hardcoded in Start(); -// addr must match the gRPC literal for the gateway-to-gRPC dial to succeed. -func New(addr string) *MicroServer { - - return &MicroServer{ - addr: addr, - } +// New initializes a new MicroServer. Since the single-listener cutover (#134) +// it takes no address: the public and metrics listen ports are constants, and +// the old gRPC dial target ($PORT) is gone with the gRPC server. +func New() *MicroServer { + return &MicroServer{} } var ( @@ -108,110 +107,86 @@ func setupDatasource() *datastores.Mysql { } func (server *MicroServer) Start() { - // Adds gRPC internal logs. This is quite verbose, so adjust as desired! - grpcLogger := grpclog.NewLoggerV2(io.Discard, os.Stdout, os.Stdout) - grpclog.SetLoggerV2(grpcLogger) - Info.Println("Starting 7Cav API version:", version) // Resolve and cache the trusted-proxy set (TRUSTED_PROXIES, ADR 0005) ONCE - // before any listener opens: the shared rest.AuthMiddleware (legacy gateway - // and new stack both delegate to it) reads this cache to resolve the client - // IP for its 401 log lines. A non-empty-but-malformed value is fatal here — - // a misconfigured trust set must not start silently trusting nothing. - // CROSS-ISSUE: #134 must preserve this call when it rebuilds the - // single-listener composition root (documentation requirement, ADR 0005). + // before any listener opens: rest.AuthMiddleware reads this cache to resolve + // the client IP for its 401 log lines. A non-empty-but-malformed value is + // fatal here — a misconfigured trust set must not start silently trusting + // nothing. if err := rest.InitTrustedProxies(); err != nil { Error.Fatalf("invalid TRUSTED_PROXIES: %v", err) } - // Phase 0 observability (PRD #112): errors-only Sentry capture, gated on - // SENTRY_DSN. Disabled (local/dev) nothing is initialised — one Info line, - // no client, no signal handler, so shutdown behaves exactly as before. - // Enabled, this BLOCKS boot before any listener opens: the dial pre-check - // (≤3s on an unreachable host) plus the startup-probe flush window (≤5s) - // — a worst-case ~8s delay on a degraded network, by design, so a broken - // pipeline is visible before traffic flows. - if setupSentry() { - flushSentryOnShutdown() + // Observability (PRD #112): errors-only Sentry capture, gated on SENTRY_DSN. + // Disabled (local/dev) nothing is initialised — one Info line, no client, no + // signal handler, so shutdown behaves exactly as before. Enabled, this + // BLOCKS boot before any listener opens: the dial pre-check (≤3s on an + // unreachable host) plus the startup-probe flush window (≤5s) — a worst-case + // ~8s delay on a degraded network, by design, so a broken pipeline is + // visible before traffic flows. The shutdown flush handler is installed only + // when capture is enabled. + if rest.SetupSentry(version) { + rest.FlushSentryOnShutdown() } - // plain-TCP listeners (no TLS — nginx terminates; see the creds note below) - grpcL, err := net.Listen("tcp", "0.0.0.0:10000") + // plain-TCP listeners (no TLS — nginx terminates the public one; the metrics + // listener is internal-only). + publicL, err := net.Listen("tcp", publicAddr) if err != nil { - Error.Fatalf("Failed to listen on 0.0.0.0:10000: %v", err) + Error.Fatalf("Failed to listen on %s: %v", publicAddr, err) } - httpL, err := net.Listen("tcp", "0.0.0.0:11000") + metricsL, err := net.Listen("tcp", metricsAddr) if err != nil { - Error.Fatalf("Failed to listen on 0.0.0.0:11000: %v", err) + Error.Fatalf("Failed to listen on %s: %v", metricsAddr, err) } ds := setupDatasource() + + // Warm the reference cache before serving: rest.New panics on a nil/cold + // cache, and the tickets routes resolve reference names through it. server.referenceCache = referencecache.New(ds) if err := server.referenceCache.Refresh(context.Background()); err != nil { Error.Fatalf("initial reference cache load failed: %v", err) } go runReferenceCacheRefresh(context.Background(), server.referenceCache) - // relevant Grpc options - // note: commenting out the creds option, because internally (nginx <-> golang) traffic is not encrypted. - // If this needed to change in the future, then we will need to refactor this method - opts := []grpc.ServerOption{ - grpc.ChainUnaryInterceptor(apiUnaryInterceptors(ds)...), - //grpc.Creds(creds), - } - - // launch goroutines for multiplexed listener - Info.Println("Starting HTTP listener") - go servHTTP(server, httpL, ds) - Info.Println("Starting GRPC listener") - servGRPC(server, grpcL, opts, ds) + Info.Println("Starting metrics listener on", metricsAddr) + go servMetrics(server, metricsL) + Info.Println("Starting public listener on", publicAddr) + servPublic(server, publicL, ds) } -// apiUnaryInterceptors is the gRPC unary interceptor chain, in the -// outermost-first order consumed by grpc.ChainUnaryInterceptor: auth outer, -// sentry inner. Sentry sits inside auth so it only sees authenticated -// requests, with the API key already on ctx for key-id tagging. No SENTRY_DSN -// → the inner interceptor is a pass-through. Sentry-inside-auth also means -// auth-layer infrastructure failures (e.g. a datastore outage producing mass -// Unauthenticated rejections) generate no Sentry events by design — accepted -// for Phase 0, revisit in the Phase 3 observability slices (#130–#132). -// -// Package-level (not inlined in Start) so the chain order is a tested -// contract — see TestAPIUnaryInterceptors_AuthOuterSentryInner_KeyIDReachesEvent -// — mirroring buildAPIHandler on the HTTP side. -func apiUnaryInterceptors(ds datastores.Datastore) []grpc.UnaryServerInterceptor { - return []grpc.UnaryServerInterceptor{ - grpcServices.NewAuthInterceptor(ds), - grpcServices.NewSentryInterceptor(), - } -} - -func servGRPC(server *MicroServer, lis net.Listener, grpcOpts []grpc.ServerOption, ds datastores.Datastore) { - // Due to the grpc-gateway setup, the GRPC service is at bottom of the relevant API call. - // As such, it requires the DB connection. But the HTTP service doesn't - service := &grpcServices.MilpacsService{Datastore: ds} - - // init gRPC servers instance - server.grpcServer = grpc.NewServer(grpcOpts...) - milpacs.RegisterMilpacServiceServer(server.grpcServer, service) - - ticketsService := &grpcServices.TicketsService{ - Datastore: ds, - ReferenceCache: server.referenceCache, - } - milpacs.RegisterTicketsServiceServer(server.grpcServer, ticketsService) +// servPublic serves the single public listener: the REST API under /api and +// the Swagger UI + OpenAPI specs everywhere else, at the same URLs the old +// grpc-gateway used. rest.New is the API handler; rest.DocsHandler serves the +// docs with the spec's info.version stamped from the build-time version. +func servPublic(server *MicroServer, lis net.Listener, ds datastores.Datastore) { + apiHandler := rest.New(ds, server.referenceCache) + docsHandler := rest.DocsHandler(version) + + root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api") { + apiHandler.ServeHTTP(w, r) + return + } + docsHandler.ServeHTTP(w, r) + }) - if err := server.grpcServer.Serve(lis); err != nil { - Error.Fatalf("unable to start external gRPC servers: %v", err) + server.publicServer = &http.Server{Handler: root} + if err := server.publicServer.Serve(lis); err != nil { + Error.Fatalf("unable to start public HTTP server: %v", err) } } -func servHTTP(server *MicroServer, lis net.Listener, ds datastores.Datastore) { - service := httpServices.Service{Address: server.addr, Datastore: ds} - server.httpServer = service.Server() - if err := server.httpServer.Serve(lis); err != nil { - Error.Fatalf("unable to start HTTP servers: %v", err) +// servMetrics serves the internal Prometheus metrics endpoint on its own +// listener. The handler is mounted at the listener root; the +// compose/nginx non-exposure keeps it off the public network (deploy config, +// not code). +func servMetrics(server *MicroServer, lis net.Listener) { + server.metricsServer = &http.Server{Handler: rest.MetricsHandler()} + if err := server.metricsServer.Serve(lis); err != nil { + Error.Fatalf("unable to start metrics HTTP server: %v", err) } } diff --git a/servers/server_test.go b/servers/server_test.go index d11ad5f..ef8d17c 100644 --- a/servers/server_test.go +++ b/servers/server_test.go @@ -135,10 +135,10 @@ func TestStart_WiresInitTrustedProxiesBeforeServing(t *testing.T) { // AuthMiddleware that reads it. require.Less(t, int(initPos), int(firstSelectorCallPos(start, "net", "Listen")), "rest.InitTrustedProxies() must run BEFORE net.Listen opens a socket — the trusted set must be cached before any request can reach the 401 log sites") - require.Less(t, int(initPos), int(firstIdentCallPos(start, "servHTTP")), - "rest.InitTrustedProxies() must run BEFORE servHTTP starts serving") - require.Less(t, int(initPos), int(firstIdentCallPos(start, "servGRPC")), - "rest.InitTrustedProxies() must run BEFORE servGRPC starts serving") + require.Less(t, int(initPos), int(firstIdentCallPos(start, "servPublic")), + "rest.InitTrustedProxies() must run BEFORE servPublic starts serving") + require.Less(t, int(initPos), int(firstIdentCallPos(start, "servMetrics")), + "rest.InitTrustedProxies() must run BEFORE servMetrics starts serving") } // TestStart_MakesMalformedTrustedProxiesFatal pins the error handling on the From 98006e810ea03aff4e27da5c49cd0aa201eeb5d1 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:27 -0400 Subject: [PATCH 6/8] fix(servers): metrics listener is best-effort, add wiring guards Review follow-up on the #134 cutover. - The internal-only metrics listener no longer takes the public API down. A bind failure now logs and serves without metrics, and a runtime Serve error is logged instead of calling Error.Fatalf. The public listener stays fatal since it is the service. The old dual stack made every listener fatal because both served traffic, which the metrics listener does not. - Extract buildPublicRouter from servPublic so the /api-vs-docs split is unit-testable without opening a listener. - Pin the Sentry boot wiring so FlushSentryOnShutdown is installed only inside the SetupSentry gate, using the same AST guard idiom as the trusted-proxy wiring. Those functions just changed packages, which is exactly when a refactor drops the call. > *This was generated by AI* --- servers/router_test.go | 43 ++++++++++++++++++++++++++++++++++ servers/server.go | 51 ++++++++++++++++++++++++++--------------- servers/server_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 servers/router_test.go diff --git a/servers/router_test.go b/servers/router_test.go new file mode 100644 index 0000000..ab0becb --- /dev/null +++ b/servers/router_test.go @@ -0,0 +1,43 @@ +package servers + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestBuildPublicRouter_SplitsApiFromDocs pins the public listener's path split +// — new hand-written routing that replaced the old grpc-gateway's implicit +// /api-vs-docs dispatch. /api* reaches the API handler; everything else (the +// docs UI, the embedded specs, and notably /metrics — which is NOT mounted on +// the public listener) reaches the docs handler. The constituent handlers are +// covered elsewhere (the golden corpus for rest.New, docs_test.go for +// DocsHandler); this guards only the composition. +func TestBuildPublicRouter_SplitsApiFromDocs(t *testing.T) { + var hit string + api := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hit = "api" }) + docs := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hit = "docs" }) + router := buildPublicRouter(api, docs) + + cases := []struct { + path string + want string + }{ + {"/api/v1/milpacs/ranks", "api"}, + {"/api/v1/tickets", "api"}, + {"/api", "api"}, + {"/", "docs"}, + {"/index.html", "docs"}, + {"/milpacs.swagger.json", "docs"}, + // /metrics is internal-only — never the API surface; it falls to the + // docs file server (which 404s it), it does NOT reach MetricsHandler. + {"/metrics", "docs"}, + } + for _, c := range cases { + hit = "" + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, c.path, nil)) + if hit != c.want { + t.Errorf("path %q routed to %q, want %q", c.path, hit, c.want) + } + } +} diff --git a/servers/server.go b/servers/server.go index 726c9dc..70a02bc 100644 --- a/servers/server.go +++ b/servers/server.go @@ -130,16 +130,12 @@ func (server *MicroServer) Start() { rest.FlushSentryOnShutdown() } - // plain-TCP listeners (no TLS — nginx terminates the public one; the metrics - // listener is internal-only). + // plain-TCP public listener (no TLS — nginx terminates it). A bind failure + // here is fatal: this listener IS the service. publicL, err := net.Listen("tcp", publicAddr) if err != nil { Error.Fatalf("Failed to listen on %s: %v", publicAddr, err) } - metricsL, err := net.Listen("tcp", metricsAddr) - if err != nil { - Error.Fatalf("Failed to listen on %s: %v", metricsAddr, err) - } ds := setupDatasource() @@ -151,8 +147,17 @@ func (server *MicroServer) Start() { } go runReferenceCacheRefresh(context.Background(), server.referenceCache) - Info.Println("Starting metrics listener on", metricsAddr) - go servMetrics(server, metricsL) + // Internal-only metrics listener — best-effort. Metrics are observability, + // not the service: a bind failure here must NOT take the public API down + // (unlike the old dual stack, where both listeners served traffic and a + // fatal bind was right for each). Log loudly and serve without metrics. + if metricsL, mErr := net.Listen("tcp", metricsAddr); mErr != nil { + Error.Printf("metrics listener bind failed on %s (%v) — continuing WITHOUT metrics; public API unaffected", metricsAddr, mErr) + } else { + Info.Println("Starting metrics listener on", metricsAddr) + go servMetrics(server, metricsL) + } + Info.Println("Starting public listener on", publicAddr) servPublic(server, publicL, ds) } @@ -162,21 +167,28 @@ func (server *MicroServer) Start() { // grpc-gateway used. rest.New is the API handler; rest.DocsHandler serves the // docs with the spec's info.version stamped from the build-time version. func servPublic(server *MicroServer, lis net.Listener, ds datastores.Datastore) { - apiHandler := rest.New(ds, server.referenceCache) - docsHandler := rest.DocsHandler(version) + root := buildPublicRouter(rest.New(ds, server.referenceCache), rest.DocsHandler(version)) + server.publicServer = &http.Server{Handler: root} + if err := server.publicServer.Serve(lis); err != nil { + Error.Fatalf("unable to start public HTTP server: %v", err) + } +} - root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// buildPublicRouter is the public listener's path split: /api* goes to the API +// handler (rest.New), everything else to the docs handler (rest.DocsHandler). +// Extracted from servPublic so the split is unit-testable without opening a +// listener (see TestBuildPublicRouter_SplitsApiFromDocs). Note: only /api* is +// the gated API surface — /metrics is NOT served here (it lives on the internal +// listener), so a public /metrics request falls through to the docs file server +// and 404s. +func buildPublicRouter(apiHandler, docsHandler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") { apiHandler.ServeHTTP(w, r) return } docsHandler.ServeHTTP(w, r) }) - - server.publicServer = &http.Server{Handler: root} - if err := server.publicServer.Serve(lis); err != nil { - Error.Fatalf("unable to start public HTTP server: %v", err) - } } // servMetrics serves the internal Prometheus metrics endpoint on its own @@ -185,8 +197,11 @@ func servPublic(server *MicroServer, lis net.Listener, ds datastores.Datastore) // not code). func servMetrics(server *MicroServer, lis net.Listener) { server.metricsServer = &http.Server{Handler: rest.MetricsHandler()} - if err := server.metricsServer.Serve(lis); err != nil { - Error.Fatalf("unable to start metrics HTTP server: %v", err) + // Internal-only observability: a Serve error must NOT take the public API + // down. Log loudly and let the public listener keep serving. ErrServerClosed + // is the clean-shutdown signal, not an error to report. + if err := server.metricsServer.Serve(lis); err != nil && err != http.ErrServerClosed { + Error.Printf("metrics HTTP server stopped: %v — public API continues without metrics", err) } } diff --git a/servers/server_test.go b/servers/server_test.go index ef8d17c..89c5945 100644 --- a/servers/server_test.go +++ b/servers/server_test.go @@ -181,3 +181,55 @@ func TestStart_MakesMalformedTrustedProxiesFatal(t *testing.T) { require.True(t, guarded, "the rest.InitTrustedProxies() error must be made fatal via Error.Fatalf in the same if-block — a swallowed error boots a misconfigured trust set silently trusting nothing (ADR 0005)") } + +// TestStart_InstallsShutdownFlushOnlyWhenSentryEnabled pins the Sentry boot +// wiring: Start must install the SIGTERM flush handler +// (rest.FlushSentryOnShutdown) ONLY inside the if-block gated on +// rest.SetupSentry(...). Installing it unconditionally would rewrite the +// process's signal semantics even when capture is disabled; dropping the call +// would silently lose final-moment error events on every redeploy (Watchtower +// recreates the container on each release). Source-level because Start blocks on +// Serve and can't be driven in-process — and because SetupSentry / +// FlushSentryOnShutdown JUST moved packages (servers -> rest) in the #134 +// cutover, the exact "a refactor silently drops the wiring" moment this idiom +// guards. +func TestStart_InstallsShutdownFlushOnlyWhenSentryEnabled(t *testing.T) { + _, start := startMethodDecl(t) + + // The gate is `if rest.SetupSentry(version) { rest.FlushSentryOnShutdown() }` + // — SetupSentry in the condition, FlushSentryOnShutdown in the body. + var gated bool + ast.Inspect(start, func(n ast.Node) bool { + ifStmt, ok := n.(*ast.IfStmt) + if !ok { + return true + } + if _, cond := selectorCall(ifStmt.Cond, "rest", "SetupSentry"); !cond { + return true + } + if _, body := selectorCall(ifStmt.Body, "rest", "FlushSentryOnShutdown"); body { + gated = true + } + return false + }) + require.True(t, gated, + "Start must call rest.FlushSentryOnShutdown() inside the `if rest.SetupSentry(...)` block — dropping or ungating it rewrites SIGTERM semantics when capture is off and loses final-moment error events on redeploy. The functions moved packages in the #134 cutover; this guard catches a dropped or ungated call.") + + // Defense-in-depth: the gated call must be the ONLY FlushSentryOnShutdown + // call in Start, so it can't also be installed unconditionally elsewhere. + count := 0 + ast.Inspect(start, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok && x.Name == "rest" && sel.Sel.Name == "FlushSentryOnShutdown" { + count++ + } + } + return true + }) + require.Equal(t, 1, count, + "expected exactly one rest.FlushSentryOnShutdown() call in Start (the SetupSentry-gated one) — a second, ungated call would install the shutdown handler even when Sentry is disabled") +} From 7d146643cc694e3c87279c711ad9cd86afad3cdf Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:27 -0400 Subject: [PATCH 7/8] refactor(types): inline RankType helpers, test public methods Match the sibling enums (rostertype and recordtype inline FormatInt/TrimPrefix) and point the tests at the public String()/RankShort() methods instead of the removed private helpers. Behavior is unchanged and RankShort stays byte-identical. > *This was generated by AI* --- types/ranktype.go | 16 +++++----------- types/ranktype_test.go | 14 ++++++++------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/types/ranktype.go b/types/ranktype.go index e1c3cc5..686ca5d 100644 --- a/types/ranktype.go +++ b/types/ranktype.go @@ -89,25 +89,19 @@ var rankTypeNames = map[RankType]string{ // String returns the wire name, or the bare decimal number for values the // catalog has no name for — mirroring the generated proto String() so the -// derived RankShort stays identical. +// derived RankShort stays identical. strconv.FormatInt (int64) matches the +// proto's EnumNumber (int32) formatting across the full range, including +// negatives (-1, not 4294967295). func (rt RankType) String() string { if name, ok := rankTypeNames[rt]; ok { return name } - return decimalString(int64(rt)) + return strconv.FormatInt(int64(rt), 10) } // RankShort is the short rank name the datastore stamps onto profiles: the // enum name with the RANK_TYPE_ prefix stripped (e.g. "COL"). For uncataloged // ids it is the bare decimal (the prefix strip is a no-op on a number). func (rt RankType) RankShort() string { - return trimRankPrefix(rt.String()) -} - -func decimalString(v int64) string { - return strconv.FormatInt(v, 10) -} - -func trimRankPrefix(name string) string { - return strings.TrimPrefix(name, "RANK_TYPE_") + return strings.TrimPrefix(rt.String(), "RANK_TYPE_") } diff --git a/types/ranktype_test.go b/types/ranktype_test.go index 7434167..e3426cd 100644 --- a/types/ranktype_test.go +++ b/types/ranktype_test.go @@ -1,6 +1,9 @@ package types -import "testing" +import ( + "strconv" + "testing" +) // TestRankType_String_MatchesProtoNames pins a representative set of rank ids // to their wire names. The values are cross-checked against the generated @@ -39,7 +42,7 @@ func TestRankType_String_MatchesProtoNames(t *testing.T) { // number, not an empty short name. func TestRankType_String_GapAndUnknownFallBackToDecimal(t *testing.T) { for _, id := range []RankType{24, 25, 99, -1} { - want := decimalString(int64(id)) + want := strconv.FormatInt(int64(id), 10) if got := id.String(); got != want { t.Errorf("RankType(%d).String() = %q, want %q", id, got, want) } @@ -47,7 +50,7 @@ func TestRankType_String_GapAndUnknownFallBackToDecimal(t *testing.T) { } // TestRankType_RankShort_StripsPrefix checks the actual RankShort derivation -// the datastore performs. +// the datastore performs, through the public RankShort() method. func TestRankType_RankShort_StripsPrefix(t *testing.T) { cases := map[RankType]string{ 1: "GOA", @@ -57,9 +60,8 @@ func TestRankType_RankShort_StripsPrefix(t *testing.T) { 31: "AR", } for id, short := range cases { - got := trimRankPrefix(id.String()) - if got != short { - t.Errorf("RankShort for RankType(%d) = %q, want %q", id, got, short) + if got := id.RankShort(); got != short { + t.Errorf("RankType(%d).RankShort() = %q, want %q", id, got, short) } } } From 695081744e0ab6574a0719d780cd3434b0e3e1d4 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:27 -0400 Subject: [PATCH 8/8] docs: correct stale post-cutover comments The cutover deleted servers/grpc, servers/gateway, and servers/sentry.go but left comments describing the old dual-stack world. Fix the load-bearing ones: a dangling reference to the deleted servers/gateway/gateway.go (rest/sentry.go), the rest package doc (it is the live single listener now, not test-mounted), the "legacy gateway reuses this until cutover" cluster across auth/gzip/metrics, and the metrics deploy-check (public /metrics returns 404 now, not 401). Also two test comments naming removed symbols. Comments only, no behavior change. > *This was generated by AI* --- datastores/datastore.go | 5 ++--- rest/auth.go | 23 ++++++++++------------- rest/gzip.go | 11 +++-------- rest/gzip_test.go | 6 +++--- rest/informational.go | 7 +++---- rest/metrics.go | 18 ++++++++---------- rest/positions_test.go | 4 ++-- rest/rest.go | 21 ++++++++------------- rest/sentry.go | 4 ---- 9 files changed, 39 insertions(+), 60 deletions(-) diff --git a/datastores/datastore.go b/datastores/datastore.go index 56ef458..493f949 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -106,7 +106,6 @@ type TicketReferenceCache interface { ExpandSubtree(ids []uint32) []uint32 } -// Conformance pin: the production cache satisfies the slice. Lives HERE (not -// next to the grpc server that also consumes the cache) so the check -// survives Phase 4's deletion of the grpc stack. +// Conformance pin: the production cache satisfies the slice. Kept here, on the +// interface it serves, so it lives with the only consumer. var _ TicketReferenceCache = (*referencecache.Cache)(nil) diff --git a/rest/auth.go b/rest/auth.go index fd8dd41..0d8915e 100644 --- a/rest/auth.go +++ b/rest/auth.go @@ -8,9 +8,7 @@ import ( "github.com/7cav/api/datastores" ) -// The HTTP auth middleware below moved here verbatim from servers/gateway -// (its original seam) for the Phase 3 rewrite: the gateway delegates to this -// implementation until it is deleted, so the two stacks can never diverge. +// The HTTP auth middleware below is the single public listener's auth tier. // The two-tier 401 behavior is golden-pinned (#106): scheme errors name the // expected header form, unknown keys get the generic line, both plain text, // no WWW-Authenticate challenge. @@ -33,8 +31,7 @@ const errBearerScheme = "Unauthorized: expected 'Authorization: Bearer ' he // (golden-pinned). Authorization (scope membership) is per-route: see // requireScope. // -// Exported because the legacy gateway chain reuses it until cutover deletes -// that stack. +// The public middleware constructor the chain composition uses (rest.New). func AuthMiddleware(ds datastores.Datastore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := datastores.ParseBearerToken(r.Header.Get("Authorization"), maxTokenLen) @@ -70,20 +67,20 @@ func AuthMiddleware(ds datastores.Datastore, next http.Handler) http.Handler { // clones the request, so the key attached to the INNER context never // reaches it — the mutable holder in the (shared parent) context is // the only channel. The id, never the bearer token (same rule as the - // Sentry key_id tag). nil holder = chain without metrics (the legacy - // gateway reuses this middleware until cutover deletes that stack). + // Sentry key_id tag). The nil-holder check is mis-wiring defense: this + // middleware always runs under metricsMiddleware in chain, so the + // holder is present in production — a nil holder means a chain + // assembled without metricsMiddleware. if labels := metricLabelsFromContext(r.Context()); labels != nil { labels.keyID = strconv.FormatUint(uint64(key.KeyId), 10) } // Attach the validated key to the request ctx so INNER consumers can // identify the caller without ever seeing the bearer token: the - // per-route scope checks (requireScope) and, until cutover deletes - // it, the legacy gateway's Sentry key-id tagging (its sentry layer - // sits inside auth). The new stack's sentry/metrics middlewares are - // UPSTREAM (outer) of auth — r.WithContext clones the request, so - // their request never carries this value; key-id reaches them via - // the context label-holder mechanism (#130), not this key. + // per-route scope checks (requireScope). The sentry/metrics + // middlewares are UPSTREAM (outer) of auth — r.WithContext clones the + // request, so their request never carries this value; key-id reaches + // them via the context label-holder mechanism (#130), not this key. next.ServeHTTP(w, r.WithContext(ContextWithKey(r.Context(), key))) }) } diff --git a/rest/gzip.go b/rest/gzip.go index c318de1..35bcd29 100644 --- a/rest/gzip.go +++ b/rest/gzip.go @@ -7,14 +7,9 @@ import ( "strings" ) -// GzipMiddleware moved here verbatim from servers/gateway (compression -// middleware) for the Phase 3 rewrite; the gateway delegates to it until -// cutover deletes that stack. It sits inside auth (401s are never gzipped) -// and outside the mux, so every routed response — including the JSON 404 — -// compresses when the client asks. -// -// Exported because the legacy gateway chain reuses it until cutover deletes -// that stack. +// GzipMiddleware is the compression layer. It sits inside auth (401s are +// never gzipped) and outside the mux, so every routed response — including +// the JSON 404 — compresses when the client asks. // // A handler-sent 1xx on a gzip-negotiated request carries Content-Encoding: // gzip in the interim response — the stdlib sends the live header map per diff --git a/rest/gzip_test.go b/rest/gzip_test.go index 9e3f64f..e842988 100644 --- a/rest/gzip_test.go +++ b/rest/gzip_test.go @@ -145,9 +145,9 @@ func TestGzip_FlushBeforeFirstWriteStripsStaleContentLength(t *testing.T) { // wire in one of the two shapes the WriteHeader variant's doc lays out, both // invisible to the handler (every Write returns nil). // Same shape as the WriteHeader variant below minus the explicit WriteHeader; -// this is the wire-level pin that survives cutover (#135 deletes -// servers/gateway and its recorder-based gzip round-trip test, until then the -// only pin on this path). +// this is the wire-level pin on this path (the old servers/gateway and its +// recorder-based gzip round-trip test were removed at the #134 cutover, so this +// is now the sole pin). func TestGzip_FirstWriteStripsStaleContentLength(t *testing.T) { const payload = `{"roster":"live","unit":"7th Cavalry","status":"active"}` diff --git a/rest/informational.go b/rest/informational.go index a6b433e..98a889f 100644 --- a/rest/informational.go +++ b/rest/informational.go @@ -20,10 +20,9 @@ import "net/http" // latch, cacheControlWriter's header stamp, gzipResponseWriter's // Content-Length strip and status latch — #175) must mirror that: forward a // non-latching 1xx WriteHeader to the delegate and latch nothing, so the -// subsequent final WriteHeader behaves exactly as a first call (#165). The -// legacy gateway's statusRecorder still latches on all 1xx — known, tracked -// in #176, dies at cutover. A new rest-chain wrapper with WriteHeader state -// starts here — and joins the shared table in informational_internal_test.go. +// subsequent final WriteHeader behaves exactly as a first call (#165). A new +// rest-chain wrapper with WriteHeader state starts here — and joins the shared +// table in informational_internal_test.go. // // (HTTP/2 would treat all 1xx informationally, but RFC 9113 removes 101 from // HTTP/2 entirely, and this server is plain HTTP/1.1 — latching on 101 is diff --git a/rest/metrics.go b/rest/metrics.go index fff1963..1fd6e31 100644 --- a/rest/metrics.go +++ b/rest/metrics.go @@ -78,9 +78,10 @@ func newMetricsRegistry() *prometheus.Registry { // // Verify at cutover, from outside the host: // -// curl https:///metrics → 401 (the public chain's auth -// tier answers; this handler is -// not mounted there) +// curl https:///metrics → 404 (falls through to the docs +// file server; the metrics handler +// is not mounted on the public +// listener) // curl http://:/ → connection refused/timeout // (port unpublished + unrouted) // @@ -115,8 +116,7 @@ type metricLabels struct { type metricLabelsContextKey struct{} // metricLabelsFromContext returns the request's label-holder, or nil when the -// metrics middleware is not in the chain (e.g. the legacy gateway, which -// reuses AuthMiddleware until cutover deletes it). +// metrics middleware is not in the chain. func metricLabelsFromContext(ctx context.Context) *metricLabels { v, _ := ctx.Value(metricLabelsContextKey{}).(*metricLabels) return v @@ -124,11 +124,9 @@ func metricLabelsFromContext(ctx context.Context) *metricLabels { // routeLabel fills the label-holder's route slot from r.Pattern — it must run // INSIDE the mux (wrapping each registered handler), the only place the -// matched pattern is set on the request the handler sees. The nil-holder -// check is NOT the legacy-gateway path (that is auth.go's nil check, on the -// middleware the gateway reuses): routeLabel mounts only inside this -// package's mux, always under metricsMiddleware — the check exists purely as -// mis-wiring defense. +// matched pattern is set on the request the handler sees. routeLabel mounts +// only inside this package's mux, always under metricsMiddleware — the +// nil-holder check exists purely as mis-wiring defense. func routeLabel(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if labels := metricLabelsFromContext(r.Context()); labels != nil { diff --git a/rest/positions_test.go b/rest/positions_test.go index 4b7961e..f25e171 100644 --- a/rest/positions_test.go +++ b/rest/positions_test.go @@ -97,11 +97,11 @@ func TestNewStack_SearchNilRosterWithNilErrorIsInternalJSON(t *testing.T) { assert.JSONEq(t, `{"code":13,"message":"datastore returned no roster","details":[]}`, rr.Body.String()) } -// A sparse lite profile must come through the mapper with its nils PRESERVED: +// A sparse lite profile must come through with its nils PRESERVED: // unset User/Rank/Primary stay null on the wire (never fabricated as zeroed // &types.User{}/&types.Rank{}) and empty Secondaries is [] — the // recording-seed shape (seedDoeLite, contract/fake_datastore_test.go) minus -// User/Rank, so every nil-guard branch in liteRosterFromProto runs unset. +// User/Rank, so every nil-guard branch in the lite-roster response runs unset. func TestNewStack_SearchSparseLiteProfilePreservesNils(t *testing.T) { h := rest.New(&fakeDatastore{findProfilesByPosition: func(string) (*types.LiteRoster, error) { return &types.LiteRoster{Profiles: map[uint64]*types.LiteProfile{2: { diff --git a/rest/rest.go b/rest/rest.go index 1f3760b..e0f3fc5 100644 --- a/rest/rest.go +++ b/rest/rest.go @@ -1,11 +1,7 @@ -// Package rest is the stdlib net/http stack that replaces the gRPC server + -// grpc-gateway pair (PRD #112, Phase 3). Two of its layers ALREADY serve -// production: the legacy gateway delegates auth and gzip to -// rest.AuthMiddleware/rest.GzipMiddleware (single source, so the stacks -// cannot diverge while both are in-tree). The handler stack itself -// (New/routes) is test-mounted only until the cutover slice (#134); this -// package is the permanent home — at cutover the single public listener -// serves every route through it, and Phase 4 deletes the old stacks. +// Package rest is the stdlib net/http stack that replaced the gRPC server + +// grpc-gateway pair (PRD #112, Phase 3); the #134 cutover removed those and +// made this the single public production listener. The single public listener +// serves every route through New/routes. // // # Middleware chain (PRD order — assembled in chain, New's composition) // @@ -26,9 +22,8 @@ // labels reach this OUTER layer via the context label-holder // (metricLabels): AuthMiddleware fills the key-id slot, the routeLabel // wrapper inside the mux fills the route slot from r.Pattern. The -// exposition is never served through this chain; the cutover slice -// (#134) mounts MetricsHandler on its own INTERNAL-ONLY listener — until -// then it is test-mounted only. +// exposition is never served through this chain; MetricsHandler is served +// on its own INTERNAL-ONLY listener (:9090, servers/server.go). // - AuthMiddleware: bearer-key validation with the golden-pinned two-tier // plain-text 401s. Runs BEFORE routing, so an unknown path without // credentials is a 401, not a 404 (golden-pinned). Scope checks are @@ -91,8 +86,8 @@ var ( // New assembles the new stack: the route mux wrapped in the PRD middleware // chain (sentry → metrics → auth (→ sentryLabel) → gzip → clean-path 307 → // mux; the one definition lives on chain below). The returned handler serves -// the /api surface; non-API paths (the docs UI) are the cutover slice's -// concern (#134) and 404 here until then. +// the /api surface; non-API paths (the docs UI) are served by rest.DocsHandler +// on the same listener (composed in servers.servPublic). // // rc is the tickets reference cache (status/priority/prefix names, the // category tree) the tickets datastore methods consume — at cutover (#134) diff --git a/rest/sentry.go b/rest/sentry.go index fe06f7b..8bd6b9e 100644 --- a/rest/sentry.go +++ b/rest/sentry.go @@ -266,10 +266,6 @@ func sentryLabel(next http.Handler) http.Handler { // // No-op when the request never passed an enabled sentry middleware: no // SENTRY_DSN (the complete-no-op guarantee), or a chain that does not mount -// it — the legacy gateway reuses AuthMiddleware (and so this choke point) -// until cutover, but its Phase 0 sentry layer sits INSIDE auth, so on that -// chain auth's 503s stay unreported until cutover (accepted Phase 0 gap, -// documented in servers/gateway/gateway.go); the new stack is what closes // it. Also a no-op // for the recovery layer's own contract-500 write after a panic: that event // is already captured, and one failure must not become two issues.