service: add RecordSpeedTest gRPC handler with JWT auth + validation
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||
)
|
||||
|
||||
// RecordSpeedTest ingests a single client-side speedtest sample from an edge
|
||||
// node. Authenticates via Bearer JWT (same scheme as Tunnel), validates the
|
||||
// request, and persists via the storage layer.
|
||||
func (s *Service) RecordSpeedTest(ctx context.Context, req *statsv1.RecordSpeedTestRequest) (*statsv1.RecordSpeedTestResponse, error) {
|
||||
// 1. JWT auth — mirrors the Tunnel handler.
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "no metadata")
|
||||
}
|
||||
authH := md.Get("authorization")
|
||||
if len(authH) == 0 {
|
||||
return nil, status.Error(codes.Unauthenticated, "no auth")
|
||||
}
|
||||
tok := strings.TrimPrefix(authH[0], "Bearer ")
|
||||
if tok == authH[0] {
|
||||
return nil, status.Error(codes.Unauthenticated, "expected Bearer")
|
||||
}
|
||||
if _, err := s.verifier.Verify(tok); err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "auth: %v", err)
|
||||
}
|
||||
|
||||
// 2. Input validation.
|
||||
if req.Node != "fl" && req.Node != "pl1" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "node: must be 'fl' or 'pl1', got %q", req.Node)
|
||||
}
|
||||
if !validCountry(req.Country) {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "country: must match ^[A-Z]{2}$ or 'XX', got %q", req.Country)
|
||||
}
|
||||
if req.DlMbps < 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "dl_mbps: must be >= 0, got %v", req.DlMbps)
|
||||
}
|
||||
if req.UlMbps < 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "ul_mbps: must be >= 0, got %v", req.UlMbps)
|
||||
}
|
||||
if req.PingMs < 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "ping_ms: must be >= 0, got %v", req.PingMs)
|
||||
}
|
||||
if req.JitterMs < 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "jitter_ms: must be >= 0, got %v", req.JitterMs)
|
||||
}
|
||||
if req.Timestamp <= 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "timestamp: must be > 0, got %d", req.Timestamp)
|
||||
}
|
||||
|
||||
// 3. Persist.
|
||||
id, err := s.store.SaveSpeedTest(ctx, storage.SpeedTest{
|
||||
Timestamp: req.Timestamp,
|
||||
Node: req.Node,
|
||||
Country: req.Country,
|
||||
DlMbps: req.DlMbps,
|
||||
UlMbps: req.UlMbps,
|
||||
PingMs: req.PingMs,
|
||||
JitterMs: req.JitterMs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "save: %v", err)
|
||||
}
|
||||
return &statsv1.RecordSpeedTestResponse{Id: id}, nil
|
||||
}
|
||||
|
||||
// validCountry returns true when c is two uppercase ASCII letters (ISO-3166-1
|
||||
// alpha-2 shape) or the literal "XX" sentinel for unknown.
|
||||
func validCountry(c string) bool {
|
||||
if len(c) != 2 {
|
||||
return false
|
||||
}
|
||||
if c == "XX" {
|
||||
return true
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if c[i] < 'A' || c[i] > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||
"github.com/bergabruh/stats-gateway/internal/auth"
|
||||
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||
)
|
||||
|
||||
// authedCtx returns a context with a valid Bearer JWT for the given subject.
|
||||
// Uses the same secret as newTestServer ("test-secret") and scope "tunnel"
|
||||
// (the only scope accepted by *auth.Verifier.Verify).
|
||||
func authedCtx(t *testing.T, sub string) context.Context {
|
||||
t.Helper()
|
||||
tok, err := auth.Issue([]byte("test-secret"), sub, "tunnel", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
func newRecordSpeedTestClient(t *testing.T) (statsv1.SpeedStatusClient, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("storage.Open: %v", err)
|
||||
}
|
||||
client, cleanup := newTestServer(t, st)
|
||||
return client, func() { cleanup(); st.Close() }
|
||||
}
|
||||
|
||||
func TestRecordSpeedTest_HappyPath(t *testing.T) {
|
||||
client, cleanup := newRecordSpeedTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := authedCtx(t, "fl-speedtest-sync")
|
||||
resp, err := client.RecordSpeedTest(ctx, &statsv1.RecordSpeedTestRequest{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Node: "fl",
|
||||
Country: "RU",
|
||||
DlMbps: 100, UlMbps: 20, PingMs: 35, JitterMs: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSpeedTest: %v", err)
|
||||
}
|
||||
if resp.Id <= 0 {
|
||||
t.Fatalf("expected id>0, got %d", resp.Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSpeedTest_RejectsBadNode(t *testing.T) {
|
||||
client, cleanup := newRecordSpeedTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := authedCtx(t, "fl-speedtest-sync")
|
||||
_, err := client.RecordSpeedTest(ctx, &statsv1.RecordSpeedTestRequest{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Node: "moon", Country: "RU",
|
||||
DlMbps: 1, UlMbps: 1, PingMs: 1, JitterMs: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected InvalidArgument for bad node, got nil")
|
||||
}
|
||||
if st, ok := status.FromError(err); !ok || st.Code() != codes.InvalidArgument {
|
||||
t.Fatalf("expected InvalidArgument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSpeedTest_RejectsNegativeMbps(t *testing.T) {
|
||||
client, cleanup := newRecordSpeedTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := authedCtx(t, "fl-speedtest-sync")
|
||||
_, err := client.RecordSpeedTest(ctx, &statsv1.RecordSpeedTestRequest{
|
||||
Timestamp: time.Now().Unix(), Node: "fl", Country: "RU",
|
||||
DlMbps: -1, UlMbps: 1, PingMs: 1, JitterMs: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected InvalidArgument for negative dl_mbps, got nil")
|
||||
}
|
||||
if st, ok := status.FromError(err); !ok || st.Code() != codes.InvalidArgument {
|
||||
t.Fatalf("expected InvalidArgument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSpeedTest_RequiresAuth(t *testing.T) {
|
||||
client, cleanup := newRecordSpeedTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.RecordSpeedTest(context.Background(), &statsv1.RecordSpeedTestRequest{
|
||||
Timestamp: time.Now().Unix(), Node: "fl", Country: "RU",
|
||||
DlMbps: 1, UlMbps: 1, PingMs: 1, JitterMs: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected Unauthenticated, got nil")
|
||||
}
|
||||
if st, ok := status.FromError(err); !ok || st.Code() != codes.Unauthenticated {
|
||||
t.Fatalf("expected Unauthenticated, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user