diff --git a/internal/service/recordspeedtest.go b/internal/service/recordspeedtest.go new file mode 100644 index 0000000..fde40bb --- /dev/null +++ b/internal/service/recordspeedtest.go @@ -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 +} diff --git a/internal/service/recordspeedtest_test.go b/internal/service/recordspeedtest_test.go new file mode 100644 index 0000000..d0799d1 --- /dev/null +++ b/internal/service/recordspeedtest_test.go @@ -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) + } +}