init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
package api
import (
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
)
func AuditMiddleware(auditSvc *service.AuditLogService) fiber.Handler {
return func(c *fiber.Ctx) error {
if auditSvc == nil {
return c.Next()
}
start := time.Now()
err := c.Next()
status := c.Response().StatusCode()
if status == 0 {
status = fiber.StatusOK
}
requestID := c.GetRespHeader(fiber.HeaderXRequestID)
if requestID == "" {
requestID = localString(c.Locals("requestid"))
}
meta := map[string]any{
"latency_ms": time.Since(start).Milliseconds(),
}
if err != nil {
meta["error"] = err.Error()
}
module, action := resolveAuditModuleAction(c)
if !shouldPersistAuditByMethod(c.Method()) {
return err
}
auditSvc.Log(c.UserContext(), service.AuditLogInput{
RequestID: requestID,
ActorUserID: localUserID(c.Locals("user_id")),
Layer: "api",
Module: module,
Action: action,
Method: c.Method(),
Path: c.Path(),
StatusCode: status,
Success: status >= 200 && status < 400 && err == nil,
IP: c.IP(),
UserAgent: c.Get(fiber.HeaderUserAgent),
Message: "HTTP " + strconv.Itoa(status),
Metadata: meta,
})
return err
}
}
func shouldPersistAuditByMethod(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case fiber.MethodPost, fiber.MethodPut, fiber.MethodPatch, fiber.MethodDelete:
return true
default:
return false
}
}
func resolveAuditModuleAction(c *fiber.Ctx) (string, string) {
method := c.Method()
path := c.Path()
normalized := strings.ToLower(strings.TrimSpace(path))
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 3 || parts[0] != "api" || parts[1] != "v1" {
return sharedconst.AuditModuleUnknown, actionFromMethod(method)
}
moduleSegment := strings.TrimSpace(parts[2])
module := moduleFromPathSegment(moduleSegment)
if moduleSegment == "bases" {
module = resolveBaseModuleVariant(c, module)
}
suffix := ""
if len(parts) > 3 {
suffix = strings.TrimSpace(parts[len(parts)-1])
}
switch suffix {
case "get-all", "dt":
return module, sharedconst.AuditActionList
case "get":
return module, sharedconst.AuditActionGet
case "create":
return module, sharedconst.AuditActionCreate
case "update":
return module, sharedconst.AuditActionUpdate
case "delete":
return module, sharedconst.AuditActionDelete
case "export":
return module, sharedconst.AuditActionExport
}
return module, actionFromMethod(method)
}
func resolveBaseModuleVariant(c *fiber.Ctx, fallback string) string {
raw := strings.TrimSpace(c.Query("category_type"))
if raw == "" {
raw = strings.TrimSpace(c.Query("base_category"))
}
if raw == "" {
raw = strings.TrimSpace(c.Query("category"))
}
switch strings.ToLower(raw) {
case "regular":
return sharedconst.AuditModuleBaseRegular
case "hems":
return sharedconst.AuditModuleBaseHEMS
default:
return fallback
}
}
func actionFromMethod(method string) string {
switch strings.ToUpper(strings.TrimSpace(method)) {
case fiber.MethodGet:
return sharedconst.AuditActionGet
case fiber.MethodPost:
return sharedconst.AuditActionCreate
case fiber.MethodPut, fiber.MethodPatch:
return sharedconst.AuditActionUpdate
case fiber.MethodDelete:
return sharedconst.AuditActionDelete
default:
return sharedconst.AuditActionCustom
}
}
func moduleFromPathSegment(segment string) string {
switch strings.ToLower(strings.TrimSpace(segment)) {
case "air-rescuer-checklist":
return sharedconst.AuditModuleAirRescuerChecklist
case "audit-logs":
return sharedconst.AuditModuleAuditLog
case "auth":
return sharedconst.AuditModuleAuth
case "bases":
return sharedconst.AuditModuleBase
case "branding", "public":
return sharedconst.AuditModuleBranding
case "contacts":
return sharedconst.AuditModuleContact
case "duty-roster":
return sharedconst.AuditModuleDutyRoster
case "dul":
return sharedconst.AuditModuleDUL
case "facilities":
return sharedconst.AuditModuleFacility
case "federal-state":
return sharedconst.AuditModuleFederalState
case "file-manager":
return sharedconst.AuditModuleFileManager
case "flight-data":
return sharedconst.AuditModuleFlightData
case "flights":
return sharedconst.AuditModuleFlight
case "forces-present":
return sharedconst.AuditModuleForcesPresent
case "health-insurance-companies":
return sharedconst.AuditModuleHealthInsuranceCompany
case "helicopter-files":
return sharedconst.AuditModuleHelicopterFile
case "helicopters":
return sharedconst.AuditModuleHelicopter
case "hems-operation", "hems-operation-category", "hems-operational-data":
return sharedconst.AuditModuleUnknown
case "hospital":
return sharedconst.AuditModuleHospital
case "icao":
return sharedconst.AuditModuleICAO
case "insurance-patient-data":
return sharedconst.AuditModuleInsurancePatientData
case "land":
return sharedconst.AuditModuleLand
case "master-settings":
return sharedconst.AuditModuleMasterSetting
case "medicine":
return sharedconst.AuditModuleMedicine
case "mission":
return sharedconst.AuditModuleMission
case "opc":
return sharedconst.AuditModuleOPC
case "patient-data":
return sharedconst.AuditModulePatientData
case "reserve-acs":
return sharedconst.AuditModuleReserveAC
case "roles":
return sharedconst.AuditModuleRole
case "users":
return sharedconst.AuditModuleUser
case "vocation":
return sharedconst.AuditModuleVocation
default:
return sharedconst.AuditModuleUnknown
}
}
func localString(v any) string {
s, _ := v.(string)
return s
}
func localUserID(v any) []byte {
id, _ := v.([]byte)
if len(id) != 16 {
return nil
}
return id
}

View File

@@ -0,0 +1,208 @@
package api
import (
"context"
"net/http"
"sync"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/audit"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
)
type mockAuditMiddlewareRepo struct {
mu sync.Mutex
entries []*audit.AuditLog
}
func (m *mockAuditMiddlewareRepo) Create(_ context.Context, entry *audit.AuditLog) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *entry
m.entries = append(m.entries, &cp)
return nil
}
func TestAuditMiddleware_SkipsGETRequests(t *testing.T) {
repo := &mockAuditMiddlewareRepo{}
auditSvc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 16, Workers: 1})
app := fiber.New()
app.Use(AuditMiddleware(auditSvc))
app.Get("/api/v1/ping", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
req, _ := http.NewRequest(http.MethodGet, "/api/v1/ping", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) != 0 {
t.Fatalf("expected no audit log entry for GET, got=%d", len(repo.entries))
}
}
func TestAuditMiddleware_LogsMutationRequests(t *testing.T) {
repo := &mockAuditMiddlewareRepo{}
auditSvc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 16, Workers: 1})
app := fiber.New()
app.Use(AuditMiddleware(auditSvc))
app.Post("/api/v1/helicopters/create", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) == 0 {
t.Fatalf("expected audit log entry for mutation request")
}
got := repo.entries[0]
if got.Layer != "api" || got.Module != sharedconst.AuditModuleHelicopter || got.Action != sharedconst.AuditActionCreate {
t.Fatalf("unexpected audit entry: layer=%s module=%s action=%s", got.Layer, got.Module, got.Action)
}
if got.StatusCode != http.StatusCreated {
t.Fatalf("expected status 201, got %d", got.StatusCode)
}
}
func TestModuleFromPathSegment(t *testing.T) {
cases := []struct {
segment string
want string
}{
{segment: "helicopters", want: sharedconst.AuditModuleHelicopter},
{segment: "bases", want: sharedconst.AuditModuleBase},
{segment: "health-insurance-companies", want: sharedconst.AuditModuleHealthInsuranceCompany},
{segment: "hems-roster", want: sharedconst.AuditModuleUnknown},
{segment: "dul", want: sharedconst.AuditModuleDUL},
{segment: "reserve-acs", want: sharedconst.AuditModuleReserveAC},
{segment: "not-registered", want: sharedconst.AuditModuleUnknown},
}
for _, tc := range cases {
t.Run(tc.segment, func(t *testing.T) {
got := moduleFromPathSegment(tc.segment)
if got != tc.want {
t.Fatalf("moduleFromPathSegment(%q) = %q, want %q", tc.segment, got, tc.want)
}
})
}
}
func TestResolveAuditModuleAction(t *testing.T) {
cases := []struct {
name string
method string
target string
wantModule string
wantAction string
}{
{
name: "datatable list",
method: http.MethodGet,
target: "/api/v1/helicopters/get-all/dt",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionList,
},
{
name: "explicit create",
method: http.MethodPost,
target: "/api/v1/helicopters/create",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionCreate,
},
{
name: "fallback method update",
method: http.MethodPatch,
target: "/api/v1/helicopters/019dbeb6-4c37-7495-b0d0-e02ed134f87d",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionUpdate,
},
{
name: "base regular variant from query",
method: http.MethodGet,
target: "/api/v1/bases/get-all?category_type=regular",
wantModule: sharedconst.AuditModuleBaseRegular,
wantAction: sharedconst.AuditActionList,
},
{
name: "base hems variant from query alias",
method: http.MethodGet,
target: "/api/v1/bases/get-all?base_category=hems",
wantModule: sharedconst.AuditModuleBaseHEMS,
wantAction: sharedconst.AuditActionList,
},
{
name: "unknown module",
method: http.MethodGet,
target: "/api/v1/ping",
wantModule: sharedconst.AuditModuleUnknown,
wantAction: sharedconst.AuditActionGet,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
app := fiber.New()
app.All("/*", func(c *fiber.Ctx) error {
gotModule, gotAction := resolveAuditModuleAction(c)
if gotModule != tc.wantModule || gotAction != tc.wantAction {
t.Fatalf("resolveAuditModuleAction(%q, %q) = (%q, %q), want (%q, %q)",
tc.method, tc.target, gotModule, gotAction, tc.wantModule, tc.wantAction)
}
return c.SendStatus(http.StatusNoContent)
})
req, _ := http.NewRequest(tc.method, tc.target, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
})
}
}

View File

@@ -0,0 +1,131 @@
package api
import (
"archive/zip"
"bytes"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
)
// buildTinyXLSX returns a minimal but structurally valid .xlsx (ZIP) payload.
func buildTinyXLSX(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
entries := map[string]string{
"[Content_Types].xml": `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>`,
"_rels/.rels": `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`,
}
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, content); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func assertValidZip(t *testing.T, b []byte) {
t.Helper()
if _, err := zip.NewReader(bytes.NewReader(b), int64(len(b))); err != nil {
t.Fatalf("payload is not a valid zip: %v", err)
}
}
func TestIsWOPIRequest(t *testing.T) {
app := fiber.New()
cases := []struct {
path string
want bool
}{
{"/wopi/files/123/contents", true},
{"/wopi/files/123", true},
{"/api/file-manager/files", false},
{"/wopimirror/x", false},
{"/", false},
}
for _, tc := range cases {
app.Get(tc.path, func(c *fiber.Ctx) error {
if got := isWOPIRequest(c); got != tc.want {
t.Errorf("isWOPIRequest(%q) = %v, want %v", tc.path, got, tc.want)
}
return c.SendStatus(fiber.StatusOK)
})
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil), -1)
if err != nil {
t.Fatalf("app.Test(%q): %v", tc.path, err)
}
_ = resp.Body.Close()
}
}
// Regression for the "template .xlsx always corrupt via WOPI" bug: the global
// gzip compress middleware must NOT compress WOPI file-content responses.
// Collabora saves the response body verbatim as the document, so a gzip stream
// there is a guaranteed-corrupt file.
func TestCompressMiddleware_SkipsWOPIContents(t *testing.T) {
src := buildTinyXLSX(t)
assertValidZip(t, src)
app := fiber.New()
// Mirror the production compress wiring (UseDefaultMiddlewares).
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
Next: func(c *fiber.Ctx) bool {
return isEventStreamRequest(c) || isWOPIRequest(c)
},
}))
streamXLSX := func(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "application/octet-stream")
c.Status(fiber.StatusOK)
c.Context().Response.SetBodyStream(io.NopCloser(bytes.NewReader(src)), len(src))
return nil
}
app.Get("/wopi/files/:id/contents", streamXLSX)
// Negative control: a non-WOPI route with a compressible body must still gzip.
app.Get("/api/echo", func(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "application/octet-stream")
return c.Send(bytes.Repeat([]byte("A"), 4096))
})
// WOPI content download with gzip offered must come back raw + valid.
req := httptest.NewRequest("GET", "/wopi/files/abc/contents", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test wopi: %v", err)
}
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
t.Fatalf("WOPI content unexpectedly encoded: Content-Encoding=%q", enc)
}
got, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
assertValidZip(t, got)
if !bytes.Equal(got, src) {
t.Fatalf("WOPI content body mutated: got %d bytes, want %d", len(got), len(src))
}
// Sanity: prove compression is actually active for non-excluded routes.
req2 := httptest.NewRequest("GET", "/api/echo", nil)
req2.Header.Set("Accept-Encoding", "gzip")
resp2, err := app.Test(req2, -1)
if err != nil {
t.Fatalf("app.Test echo: %v", err)
}
if enc := resp2.Header.Get("Content-Encoding"); enc != "gzip" {
t.Fatalf("expected gzip on non-WOPI route, got Content-Encoding=%q (compress not active - test invalid)", enc)
}
_ = resp2.Body.Close()
}

View File

@@ -0,0 +1,471 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/helmet"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/gofiber/fiber/v2/middleware/timeout"
"github.com/gofiber/fiber/v2/utils"
"github.com/prometheus/client_golang/prometheus"
"wucher/internal/config"
"wucher/internal/shared/pkg/apperrorsx"
)
var (
httpMetricsRegisterOnce = sync.Once{}
httpRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"method", "path", "status"})
httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path", "status"})
)
// UseDefaultMiddlewares applies baseline best-practice middleware.
func UseDefaultMiddlewares(app *fiber.App, cfg config.HTTPConfig) {
registerHTTPMetrics()
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: func(c *fiber.Ctx, err any) {
slog.Error("panic recovered", "method", c.Method(), "path", c.Path(), "error", err, "stack", string(debug.Stack()))
},
}))
app.Use(requestid.New(requestid.Config{
Header: fiber.HeaderXRequestID,
ContextKey: "requestid",
Generator: utils.UUIDv4,
}))
app.Use(func(c *fiber.Ctx) error {
// Use a request-scoped context that keeps request values but is not canceled
// as soon as fasthttp starts draining on server shutdown.
c.SetUserContext(context.WithoutCancel(c.Context()))
return c.Next()
})
app.Use(httpAccessLogMiddleware())
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
Next: func(c *fiber.Ctx) bool {
return isEventStreamRequest(c) || isWOPIRequest(c)
},
}))
app.Use(requestTimeoutMiddleware(cfg))
app.Use(httpMetricsMiddleware())
app.Use(helmet.New(helmet.Config{
XFrameOptions: "DENY",
ReferrerPolicy: "no-referrer",
PermissionPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
HSTSMaxAge: cfg.HSTSMaxAge,
HSTSPreloadEnabled: cfg.HSTSMaxAge > 0,
}))
app.Use(disallowUnsafeMethods())
corsAllowCredentials := cfg.CORSAllowCredentials && !containsWildcardOrigin(cfg.CORSAllowOrigins)
app.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORSAllowOrigins,
AllowMethods: cfg.CORSAllowMethods,
AllowHeaders: cfg.CORSAllowHeaders,
ExposeHeaders: cfg.CORSExposeHeaders,
AllowCredentials: corsAllowCredentials,
MaxAge: cfg.CORSMaxAge,
}))
app.Use(limiter.New(limiter.Config{
Max: cfg.RateLimitMax,
Expiration: cfg.RateLimitWindow,
Next: func(c *fiber.Ctx) bool {
path := c.Path()
return c.Method() == fiber.MethodOptions ||
path == "/health" ||
strings.HasPrefix(path, "/swagger")
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(cfg.RateLimitWindow.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
}))
// Per-endpoint limiter (exact method+path match), configured from env/config.
// Applied in addition to the global limiter above.
endpointKeys := make([]string, 0, len(cfg.RateLimitByEndpoint))
for key := range cfg.RateLimitByEndpoint {
endpointKeys = append(endpointKeys, key)
}
sort.Strings(endpointKeys)
for _, endpointKey := range endpointKeys {
rule := cfg.RateLimitByEndpoint[endpointKey]
currentEndpointKey := endpointKey
currentRule := rule
app.Use(limiter.New(limiter.Config{
Max: currentRule.Max,
Expiration: currentRule.Window,
Next: func(c *fiber.Ctx) bool {
return methodPathKey(c.Method(), c.Path()) != currentEndpointKey
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(currentRule.Window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": fmt.Sprintf("rate limit exceeded for %s", currentEndpointKey),
}},
})
},
}))
}
}
func httpAccessLogMiddleware() fiber.Handler {
requestLogger := slog.Default()
return func(c *fiber.Ctx) error {
started := time.Now()
err := c.Next()
if c.Path() == "/health" {
return err
}
statusCode := c.Response().StatusCode()
level := slog.LevelInfo
if statusCode >= fiber.StatusInternalServerError || err != nil {
level = slog.LevelError
} else if statusCode >= fiber.StatusBadRequest {
level = slog.LevelWarn
}
requestID := strings.TrimSpace(fmt.Sprint(c.Locals("requestid")))
if requestID == "" || requestID == "<nil>" {
requestID = strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
}
args := []any{
slog.String("request_id", requestID),
slog.String("method", c.Method()),
slog.String("path", c.Path()),
slog.Int("status", statusCode),
slog.Duration("latency", time.Since(started)),
slog.String("ip", c.IP()),
}
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail))); detail != "" && detail != "<nil>" {
args = append(args, slog.String("internal_detail", detail))
}
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorDetail))); detail != "" && detail != "<nil>" {
args = append(args, slog.String("detail", detail))
}
if err != nil {
args = append(args, slog.Any("error", err))
args = append(args, slog.Any("error_chain", buildErrorChain(err)))
}
if statusCode >= fiber.StatusInternalServerError {
if body := strings.TrimSpace(string(c.Response().Body())); body != "" {
args = append(args, slog.String("response_body", truncateForLog(body, 1800)))
}
}
requestLogger.Log(c.UserContext(), level, "http request", args...)
return err
}
}
func buildErrorChain(err error) []string {
if err == nil {
return nil
}
chain := make([]string, 0, 8)
seen := map[string]struct{}{}
for current := err; current != nil; current = errors.Unwrap(current) {
msg := current.Error()
if msg == "" {
msg = fmt.Sprintf("%T", current)
}
key := fmt.Sprintf("%T:%s", current, msg)
if _, ok := seen[key]; ok {
break
}
seen[key] = struct{}{}
chain = append(chain, fmt.Sprintf("%T: %s", current, msg))
}
return chain
}
func truncateForLog(v string, max int) string {
if max <= 0 || len(v) <= max {
return v
}
if max <= 3 {
return v[:max]
}
return v[:max-3] + "..."
}
func httpMetricsMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
started := time.Now()
method := normalizeHTTPMethod(c.Method())
err := c.Next()
path := metricsPathLabel(c)
if shouldSkipHTTPMetrics(path) {
return err
}
status := strconv.Itoa(c.Response().StatusCode())
httpRequestsTotal.WithLabelValues(method, path, status).Inc()
httpRequestDurationSeconds.WithLabelValues(method, path, status).Observe(time.Since(started).Seconds())
return err
}
}
func registerHTTPMetrics() {
httpMetricsRegisterOnce.Do(func() {
registerHTTPCollector(httpRequestsTotal)
registerHTTPCollector(httpRequestDurationSeconds)
})
}
func registerHTTPCollector(collector prometheus.Collector) {
if err := prometheus.Register(collector); err != nil {
var alreadyRegistered prometheus.AlreadyRegisteredError
if errors.As(err, &alreadyRegistered) {
return
}
panic(err)
}
}
func shouldSkipHTTPMetrics(path string) bool {
return path == "/metrics" || path == "/health"
}
func metricsPathLabel(c *fiber.Ctx) string {
route := c.Route()
if route != nil {
if routePath := strings.TrimSpace(utils.CopyString(route.Path)); routePath != "" {
return routePath
}
}
return "unmatched"
}
func normalizeHTTPMethod(method string) string {
method = strings.ToUpper(strings.TrimSpace(utils.CopyString(method)))
if method == "" {
return "UNKNOWN"
}
switch method {
case fiber.MethodGet,
fiber.MethodHead,
fiber.MethodPost,
fiber.MethodPut,
fiber.MethodPatch,
fiber.MethodDelete,
fiber.MethodOptions,
fiber.MethodTrace,
fiber.MethodConnect:
return method
default:
return "OTHER"
}
}
func requestTimeoutMiddleware(cfg config.HTTPConfig) fiber.Handler {
defaultTimeout := cfg.RequestTimeout
overrides := cfg.RequestTimeoutByPath
if defaultTimeout <= 0 && len(overrides) == 0 {
return func(c *fiber.Ctx) error { return c.Next() }
}
return func(c *fiber.Ctx) error {
if isEventStreamRequest(c) {
return c.Next()
}
requestTimeout := defaultTimeout
if override, ok := timeoutForRequest(overrides, c.Method(), c.Path(), c.Route()); ok {
requestTimeout = override
}
if requestTimeout <= 0 {
return c.Next()
}
return timeout.NewWithContext(func(c *fiber.Ctx) error {
return c.Next()
}, requestTimeout)(c)
}
}
func isEventStreamRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
path := strings.TrimSpace(c.Path())
if strings.HasSuffix(path, "/file-manager/events") {
return true
}
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
return strings.Contains(accept, "text/event-stream")
}
// isWOPIRequest reports whether the request targets the Collabora/WOPI
// endpoints. WOPI file-content responses carry raw office bytes (an .xlsx is a
// ZIP container that is already compressed); gzipping them makes Collabora
// store the compressed stream verbatim as the document, producing a file that
// is consistently corrupt. Office/JSON WOPI payloads gain nothing from gzip, so
// the whole group is excluded from the compress middleware.
func isWOPIRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
return strings.HasPrefix(strings.TrimSpace(c.Path()), "/wopi/")
}
func timeoutForRequest(overrides map[string]time.Duration, method, path string, route *fiber.Route) (time.Duration, bool) {
if len(overrides) == 0 {
return 0, false
}
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
trimmedPath := strings.TrimSpace(path)
if route != nil {
registeredPath := strings.TrimSpace(route.Path)
if registeredPath != "" && registeredPath != "/" {
key := timeoutOverrideKey(normalizedMethod, registeredPath)
if v, ok := overrides[key]; ok {
return v, true
}
}
}
if v, ok := overrides[timeoutOverrideKey(normalizedMethod, trimmedPath)]; ok {
return v, true
}
for key, duration := range overrides {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 {
continue
}
if strings.ToUpper(strings.TrimSpace(parts[0])) != normalizedMethod {
continue
}
if routePatternMatches(strings.TrimSpace(parts[1]), trimmedPath) {
return duration, true
}
}
return 0, false
}
func methodPathKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
func timeoutOverrideKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
func routePatternMatches(pattern, path string) bool {
if pattern == "" {
return false
}
if pattern == path {
return true
}
pSeg := splitPathSegments(pattern)
rSeg := splitPathSegments(path)
if len(pSeg) == 0 && len(rSeg) == 0 {
return true
}
i := 0
for ; i < len(pSeg); i++ {
segment := pSeg[i]
if segment == "*" || strings.HasPrefix(segment, "*") {
return true
}
if i >= len(rSeg) {
return false
}
if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "+") {
continue
}
if segment != rSeg[i] {
return false
}
}
return i == len(rSeg)
}
func splitPathSegments(path string) []string {
trimmed := strings.TrimSpace(path)
trimmed = strings.TrimPrefix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func disallowUnsafeMethods() fiber.Handler {
return func(c *fiber.Ctx) error {
if c.Method() == fiber.MethodTrace || c.Method() == "TRACK" {
return c.SendStatus(fiber.StatusMethodNotAllowed)
}
return c.Next()
}
}
func containsWildcardOrigin(origins string) bool {
for _, origin := range strings.Split(origins, ",") {
if strings.TrimSpace(origin) == "*" {
return true
}
}
return false
}

View File

@@ -0,0 +1,586 @@
package api
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"wucher/internal/config"
"wucher/internal/shared/pkg/apperrorsx"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
)
func testHTTPConfig() config.HTTPConfig {
return config.HTTPConfig{
CORSAllowOrigins: "http://localhost:3000",
CORSAllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
CORSAllowHeaders: "Origin,Content-Type,Accept,Authorization",
CORSExposeHeaders: "X-Request-ID",
CORSAllowCredentials: true,
CORSMaxAge: 300,
RateLimitMax: 100,
RateLimitWindow: time.Minute,
RequestTimeout: 0,
RequestTimeoutByPath: map[string]time.Duration{},
HSTSMaxAge: 0,
}
}
func TestUseDefaultMiddlewares_CORSHeaders(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("expected allow-origin http://localhost:3000, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("expected allow-credentials true, got %q", got)
}
if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("expected X-Frame-Options DENY, got %q", got)
}
if got := resp.Header.Get("X-Request-Id"); got == "" {
t.Fatalf("expected request id header to be set")
}
}
func TestUseDefaultMiddlewares_BlockTrace(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.All("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodTrace, "/ping", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RateLimiter(t *testing.T) {
cfg := testHTTPConfig()
cfg.RateLimitMax = 1
cfg.RateLimitWindow = time.Minute
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/limited", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
firstReq, _ := http.NewRequest(http.MethodGet, "/limited", nil)
firstResp, err := app.Test(firstReq)
if err != nil {
t.Fatalf("first request app.Test: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", firstResp.StatusCode)
}
secondReq, _ := http.NewRequest(http.MethodGet, "/limited", nil)
secondResp, err := app.Test(secondReq)
if err != nil {
t.Fatalf("second request app.Test: %v", err)
}
if secondResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", secondResp.StatusCode)
}
if got := secondResp.Header.Get("Retry-After"); got == "" {
t.Fatalf("expected Retry-After header on 429 response")
}
}
func TestUseDefaultMiddlewares_EndpointRateLimiter(t *testing.T) {
cfg := testHTTPConfig()
cfg.RateLimitMax = 100
cfg.RateLimitByEndpoint = map[string]config.RateLimitRule{
"POST /auth/login": {
Max: 1,
Window: time.Minute,
},
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Post("/auth/login", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
app.Post("/auth/refresh", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req1, _ := http.NewRequest(http.MethodPost, "/auth/login", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first login request app.Test: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first login request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/auth/login", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second login request app.Test: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second login request 429, got %d", resp2.StatusCode)
}
otherReq, _ := http.NewRequest(http.MethodPost, "/auth/refresh", nil)
otherResp, err := app.Test(otherReq)
if err != nil {
t.Fatalf("other endpoint request app.Test: %v", err)
}
if otherResp.StatusCode != http.StatusOK {
t.Fatalf("expected other endpoint request 200, got %d", otherResp.StatusCode)
}
}
func TestUseDefaultMiddlewares_WildcardOriginDisablesCredentials(t *testing.T) {
cfg := testHTTPConfig()
cfg.CORSAllowOrigins = "*"
cfg.CORSAllowCredentials = true
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("expected allow-origin *, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("expected allow-credentials to be omitted, got %q", got)
}
}
func TestUseDefaultMiddlewares_RequestContextDetachedFromShutdown(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ctx", func(c *fiber.Ctx) error {
if c.UserContext() == nil {
return c.SendStatus(http.StatusInternalServerError)
}
if c.UserContext().Done() != nil {
return c.SendStatus(http.StatusInternalServerError)
}
return c.SendStatus(http.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/ctx", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_CompressesLargeJSONWhenGzipAccepted(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
largePayload := map[string]string{
"data": strings.Repeat("x", 8*1024),
}
app.Get("/large-json", func(c *fiber.Ctx) error {
return c.JSON(largePayload)
})
req, _ := http.NewRequest(http.MethodGet, "/large-json", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("expected Content-Encoding gzip, got %q", got)
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer gz.Close()
var payload map[string]string
if err := json.NewDecoder(gz).Decode(&payload); err != nil {
t.Fatalf("decode json: %v", err)
}
if payload["data"] != largePayload["data"] {
t.Fatalf("unexpected payload body")
}
}
func TestUseDefaultMiddlewares_RequestTimeoutExceeded(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-time.After(50 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/slow", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusRequestTimeout {
t.Fatalf("expected 408, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutNormalRequestUnaffected(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 200 * time.Millisecond
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/fast", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/fast", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverride(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
cfg.RequestTimeoutByPath = map[string]time.Duration{
"GET /slow": 120 * time.Millisecond,
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-time.After(40 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/slow", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with override timeout, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverrideDynamicRoute(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
cfg.RequestTimeoutByPath = map[string]time.Duration{
"GET /users/:id": 120 * time.Millisecond,
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/users/:id", func(c *fiber.Ctx) error {
select {
case <-time.After(40 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/users/123", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with dynamic route override timeout, got %d", resp.StatusCode)
}
}
func TestHTTPAccessLogMiddlewareIncludesErrorDetail(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/bad", func(c *fiber.Ctx) error {
return response.WriteErrors(c, http.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Title: "Validation error",
Detail: "name is required",
}})
})
req, _ := http.NewRequest(http.MethodGet, "/bad", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "name is required") {
t.Fatalf("expected sanitized response detail, got %s", string(body))
}
if !strings.Contains(string(body), `"detail":"validation failed"`) {
t.Fatalf("expected generic response detail, got %s", string(body))
}
if !strings.Contains(logBuf.String(), "name is required") {
t.Fatalf("expected access log to include original detail, got %s", logBuf.String())
}
}
func TestHTTPAccessLogMiddlewareIncludesResponseBodyOnServerError(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/boom", func(c *fiber.Ctx) error {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "500",
"title": "Update failed",
"detail": "db deadlock on takeover update",
}},
})
})
req, _ := http.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if !strings.Contains(logBuf.String(), "db deadlock on takeover update") {
t.Fatalf("expected access log to include response body detail, got %s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "response_body") {
t.Fatalf("expected access log to include response_body field, got %s", logBuf.String())
}
}
func TestHTTPAccessLogMiddlewareIncludesInternalErrorDetail(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/boom", func(c *fiber.Ctx) error {
c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail, "action=UpdateTakeover | error=reserve update failed | chain=*errors.errorString: reserve update failed")
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "500",
"title": "Update failed",
"detail": "internal server error",
}},
})
})
req, _ := http.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if !strings.Contains(logBuf.String(), "internal_detail") {
t.Fatalf("expected access log to include internal_detail field, got %s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "reserve update failed") {
t.Fatalf("expected access log to include internal error detail, got %s", logBuf.String())
}
}
func TestUseDefaultMiddlewares_HTTPMetricsUsesRoutePatternLabels(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/users/:id", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
app.Get("/metrics", func(c *fiber.Ctx) error {
metricsHandler(c.Context())
return nil
})
beforeCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201")
beforeHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201")
req, _ := http.NewRequest(http.MethodGet, "/users/123", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
afterCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201")
afterHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201")
if afterCounter <= beforeCounter {
t.Fatalf("expected counter to increase, before=%v after=%v", beforeCounter, afterCounter)
}
if afterHistogram <= beforeHistogram {
t.Fatalf("expected histogram count to increase, before=%v after=%v", beforeHistogram, afterHistogram)
}
metricsReq, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp, err := app.Test(metricsReq)
if err != nil {
t.Fatalf("metrics app.Test: %v", err)
}
if metricsResp.StatusCode != http.StatusOK {
t.Fatalf("expected /metrics status 200, got %d", metricsResp.StatusCode)
}
body, err := io.ReadAll(metricsResp.Body)
if err != nil {
t.Fatalf("read /metrics body: %v", err)
}
metricsBody := string(body)
if !strings.Contains(metricsBody, "http_requests_total") {
t.Fatalf("expected http_requests_total in /metrics output")
}
if !strings.Contains(metricsBody, "http_request_duration_seconds") {
t.Fatalf("expected http_request_duration_seconds in /metrics output")
}
if !strings.Contains(metricsBody, "path=\"/users/:id\"") {
t.Fatalf("expected route-pattern path label in /metrics output")
}
beforeMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200")
beforeMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200")
metricsReq2, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp2, err := app.Test(metricsReq2)
if err != nil {
t.Fatalf("metrics second app.Test: %v", err)
}
if metricsResp2.StatusCode != http.StatusOK {
t.Fatalf("expected second /metrics status 200, got %d", metricsResp2.StatusCode)
}
afterMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200")
afterMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200")
if afterMetricsCounter != beforeMetricsCounter {
t.Fatalf("expected /metrics to be excluded from counter, before=%v after=%v", beforeMetricsCounter, afterMetricsCounter)
}
if afterMetricsHistogram != beforeMetricsHistogram {
t.Fatalf("expected /metrics to be excluded from histogram, before=%v after=%v", beforeMetricsHistogram, afterMetricsHistogram)
}
}
func TestNormalizeHTTPMethod(t *testing.T) {
tests := []struct {
name string
input string
output string
}{
{name: "get", input: "GET", output: "GET"},
{name: "lowercase", input: "post", output: "POST"},
{name: "trimmed", input: " put ", output: "PUT"},
{name: "empty", input: "", output: "UNKNOWN"},
{name: "custom", input: "GETT", output: "OTHER"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeHTTPMethod(tt.input); got != tt.output {
t.Fatalf("expected %q, got %q", tt.output, got)
}
})
}
}
func metricCounterValue(t *testing.T, vec *prometheus.CounterVec, labels ...string) float64 {
t.Helper()
m := &dto.Metric{}
if err := vec.WithLabelValues(labels...).Write(m); err != nil {
t.Fatalf("read counter metric: %v", err)
}
return m.GetCounter().GetValue()
}
func metricHistogramCount(t *testing.T, vec *prometheus.HistogramVec, labels ...string) uint64 {
t.Helper()
observer, err := vec.GetMetricWithLabelValues(labels...)
if err != nil {
t.Fatalf("load histogram metric: %v", err)
}
metric, ok := observer.(prometheus.Metric)
if !ok {
t.Fatalf("histogram observer does not implement prometheus.Metric")
}
m := &dto.Metric{}
if err := metric.Write(m); err != nil {
t.Fatalf("read histogram metric: %v", err)
}
if m.GetHistogram() == nil {
t.Fatalf("histogram metric not available for labels %v", labels)
}
return m.GetHistogram().GetSampleCount()
}

View File

@@ -0,0 +1,9 @@
package api
type PinProtectedEndpoint struct {
Method string
Path string
Action string
}
var PinProtectedEndpoints = []PinProtectedEndpoint{}

View File

@@ -0,0 +1,64 @@
package api
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestPinProtectedEndpoints_Definition(t *testing.T) {
seen := map[string]struct{}{}
for _, ep := range PinProtectedEndpoints {
if ep.Method == "" || ep.Path == "" || ep.Action == "" {
t.Fatalf("invalid pin-protected endpoint: %#v", ep)
}
key := ep.Method + " " + ep.Path
if _, ok := seen[key]; ok {
t.Fatalf("duplicate pin-protected endpoint %s", key)
}
seen[key] = struct{}{}
}
}
func TestPinProtectedEndpoints_RoutesHavePinMiddleware(t *testing.T) {
if len(PinProtectedEndpoints) == 0 {
return
}
app := fiber.New()
RegisterRoutes(app, buildRouteDependenciesForTest())
routes := app.GetRoutes(true)
for _, ep := range PinProtectedEndpoints {
route := findRoute(routes, ep.Method, ep.Path)
if route == nil {
t.Fatalf("pin-protected route %s %s is not registered", ep.Method, ep.Path)
}
minHandlers := 3 // pin + permission + handler
if ep.Path == "/api/v1/auth/invite" {
minHandlers = 4 // auth + pin + permission + handler (no group auth middleware)
}
if len(route.Handlers) < minHandlers {
t.Fatalf("route %s %s expected at least %d handlers, got %d", ep.Method, ep.Path, minHandlers, len(route.Handlers))
}
}
}
func findRoute(routes []fiber.Route, method, path string) *fiber.Route {
for i := range routes {
if routes[i].Method == method && routes[i].Path == path {
return &routes[i]
}
}
return nil
}
func TestPinProtectedEndpoints_ExpectedMethods(t *testing.T) {
for _, ep := range PinProtectedEndpoints {
switch ep.Method {
case http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete:
default:
t.Fatalf("unexpected method %q on pin-protected endpoint %s", ep.Method, ep.Path)
}
}
}

140
internal/app/api/routes.go Normal file
View File

@@ -0,0 +1,140 @@
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
samlauth "wucher/internal/auth"
"wucher/internal/service"
"wucher/internal/shared/pkg/metrics"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
var metricsHandler = fasthttpadaptor.NewFastHTTPHandler(metrics.PromHTTPHandler())
type RouteServices struct {
Auth *service.AuthService
Audit *service.AuditLogService
WOPI *service.WOPIService
}
type RouteHandlers struct {
User *handlers.UserHandler
Contact *handlers.ContactHandler
Health *handlers.HealthHandler
BuildInfo *handlers.BuildInfoHandler
Auth *handlers.AuthHandler
Role *handlers.RoleHandler
Helicopter *handlers.HelicopterHandler
HelicopterUsage *handlers.HelicopterUsageHandler
HelicopterFile *handlers.HelicopterFileHandler
Flight *handlers.FlightHandler
FlightData *handlers.FlightDataHandler
Mission *handlers.MissionHandler
FMReport *handlers.FMReportHandler
ReserveAc *handlers.ReserveAcHandler
Takeover *handlers.TakeoverHandler
OtherPerson *handlers.OtherPersonHandler
Facility *handlers.FacilityHandler
Base *handlers.BaseHandler
Medicine *handlers.MedicineHandler
Vocation *handlers.VocationHandler
Opc *handlers.OpcHandler
ForcesPresent *handlers.ForcesPresentHandler
Hospital *handlers.HospitalHandler
HealthInsuranceCompanies *handlers.HealthInsuranceCompaniesHandler
FederalState *handlers.FederalStateHandler
ICAO *handlers.ICAOHandler
Land *handlers.LandHandler
MasterSettings *handlers.MasterSettingsHandler
Branding *handlers.BrandingHandler
FileManagerFolder *handlers.FileManagerFolderHandler
FileManagerFile *handlers.FileManagerFileHandler
FileManagerAttachment *handlers.FileManagerAttachmentHandler
FileManagerEvents *handlers.FileManagerEventsHandler
HelicopterFileEvents *handlers.HelicopterFileEventsHandler
DutyRoster *handlers.DutyRosterHandler
AirRescuerChecklist *handlers.AirRescuerChecklistHandler
InsurancePatientData *handlers.InsurancePatientDataHandler
PatientData *handlers.PatientDataHandler
HEMSOperationalData *handlers.HEMSOperationalDataHandler
HEMSOperationCategory *handlers.HEMSOperationCategoryHandler
HEMSOperation *handlers.HEMSOperationHandler
Complaint *handlers.ComplaintHandler
EASARelease *handlers.EASAReleaseHandler
ActionSignoff *handlers.ActionSignoffHandler
MCF *handlers.MCFHandler
DUL *handlers.DULHandler
FleetStatus *handlers.FleetStatusHandler
SAMLAuth *samlauth.Handler
}
type RouteDependencies struct {
Handlers RouteHandlers
Services RouteServices
}
func RegisterRoutes(app *fiber.App, deps RouteDependencies) {
h := deps.Handlers
s := deps.Services
app.Get("/health", h.Health.Handle)
app.Get("/version", h.BuildInfo.Handle)
app.Get("/api/version", h.BuildInfo.Handle)
app.Get("/api/build-info", h.BuildInfo.Handle)
app.Get("/metrics", func(c *fiber.Ctx) error {
metricsHandler(c.Context())
return nil
})
api := app.Group("/api")
api.Use(AuditMiddleware(s.Audit))
v1 := api.Group("/v1")
registerAuthRoutes(v1, h.Auth, s.Auth, h.SAMLAuth)
registerUserRoutes(v1, h.User, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerContactRoutes(v1, h.Contact, middleware.AuthRequired(s.Auth), s.Auth)
registerRoleRoutes(v1, h.Role, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterRoutes(v1, h.Helicopter, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterUsageRoutes(v1, h.HelicopterUsage, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterFileRoutes(v1, h.HelicopterFile, h.HelicopterFileEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFlightRoutes(v1, h.Flight, middleware.AuthRequired(s.Auth), s.Auth)
registerFlightDataRoutes(v1, h.FlightData, middleware.AuthRequired(s.Auth), s.Auth)
registerMissionRoutes(v1, h.Mission, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFMReportRoutes(v1, h.FMReport, middleware.AuthRequired(s.Auth), s.Auth)
registerReserveAcRoutes(v1, h.ReserveAc, h.Takeover, middleware.AuthRequired(s.Auth), s.Auth)
registerTakeoverRoutes(v1, h.Takeover, middleware.AuthRequired(s.Auth), s.Auth)
registerOtherPersonRoutes(v1, h.OtherPerson, middleware.AuthRequired(s.Auth), s.Auth)
registerLastFlightsInspectionRoutes(v1, h.ReserveAc, middleware.AuthRequired(s.Auth), s.Auth)
registerFacilityRoutes(v1, h.Facility, middleware.AuthRequired(s.Auth), s.Auth)
registerBaseRoutes(v1, h.Base, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerMedicineRoutes(v1, h.Medicine, middleware.AuthRequired(s.Auth), s.Auth)
registerVocationRoutes(v1, h.Vocation, middleware.AuthRequired(s.Auth), s.Auth)
registerOpcRoutes(v1, h.Opc, middleware.AuthRequired(s.Auth), s.Auth)
registerForcesPresentRoutes(v1, h.ForcesPresent, middleware.AuthRequired(s.Auth), s.Auth)
registerHospitalRoutes(v1, h.Hospital, middleware.AuthRequired(s.Auth), s.Auth)
registerHealthInsuranceCompaniesRoutes(v1, h.HealthInsuranceCompanies, middleware.AuthRequired(s.Auth), s.Auth)
registerFederalStateRoutes(v1, h.FederalState, middleware.AuthRequired(s.Auth), s.Auth)
registerICAORoutes(v1, h.ICAO, middleware.AuthRequired(s.Auth), s.Auth)
registerLandRoutes(v1, h.Land, middleware.AuthRequired(s.Auth), s.Auth)
registerMasterSettingsRoutes(v1, h.MasterSettings, middleware.AuthRequired(s.Auth), s.Auth)
registerBrandingRoutes(v1, h.Branding)
registerFileManagerRoutes(v1, h.FileManagerFolder, h.FileManagerFile, h.FileManagerAttachment, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerWOPIRoutes(app, h.Takeover, s.WOPI)
registerDutyRosterRoutes(v1, h.DutyRoster, middleware.AuthRequired(s.Auth), s.Auth)
registerAirRescuerChecklistRoutes(v1, h.AirRescuerChecklist, middleware.AuthRequired(s.Auth), s.Auth)
registerInsurancePatientDataRoutes(v1, h.InsurancePatientData, middleware.AuthRequired(s.Auth), s.Auth)
registerPatientDataRoutes(v1, h.PatientData, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationalDataRoutes(v1, h.HEMSOperationalData, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationCategoryRoutes(v1, h.HEMSOperationCategory, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationRoutes(v1, h.HEMSOperation, middleware.AuthRequired(s.Auth), s.Auth)
registerComplaintRoutes(v1, h.Complaint, middleware.AuthRequired(s.Auth), s.Auth)
registerEASAReleaseRoutes(v1, h.EASARelease, middleware.AuthRequired(s.Auth), s.Auth)
registerActionSignoffRoutes(v1, h.ActionSignoff, middleware.AuthRequired(s.Auth), s.Auth)
registerMCFRoutes(v1, h.MCF, middleware.AuthRequired(s.Auth), s.Auth)
registerDULRoutes(v1, h.DUL, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFleetStatusRoutes(v1, h.FleetStatus, middleware.AuthRequired(s.Auth), s.Auth)
registerAuditRoutes(v1, handlers.NewAuditHandler(s.Audit), middleware.AuthRequired(s.Auth), s.Auth)
registerSwaggerRoutes(app)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerActionSignoffRoutes(v1 fiber.Router, h *handlers.ActionSignoffHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/action-signoffs", authMiddleware)
g.Get("/get-by-flight/:flight_id", middleware.PermissionRequired(authSvc, "action_signoff.read"), h.GetByFlight)
g.Post("/sign/:helicopter_id", middleware.PermissionRequired(authSvc, "action_signoff.sign"), h.Sign)
}

View File

@@ -0,0 +1,23 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAirRescuerChecklistRoutes(v1 fiber.Router, checklistHandler *handlers.AirRescuerChecklistHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
checklists := v1.Group("/air-rescuer-checklist", authMiddleware)
checklists.Post("/create", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.create"), checklistHandler.Create)
checklists.Get("/get-all", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.List)
checklists.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.ListDatatable)
checklists.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.Get)
checklists.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.Update)
checklists.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.delete"), checklistHandler.Delete)
checklists.Post("/:uuid/items/create", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.CreateItem)
checklists.Patch("/:uuid/items/reorder", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.ReorderItems)
checklists.Patch("/items/update/:item_uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.UpdateItem)
checklists.Delete("/items/delete/:item_uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.delete"), checklistHandler.DeleteItem)
}

View File

@@ -0,0 +1,14 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuditRoutes(v1 fiber.Router, auditHandler *handlers.AuditHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
audits := v1.Group("/audit-logs", authMiddleware)
audits.Get("/get-all", middleware.PermissionRequiredReusable(authSvc, "audit.read"), auditHandler.List)
}

View File

@@ -0,0 +1,142 @@
package api
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
samlauth "wucher/internal/auth"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuthRoutes(v1 fiber.Router, authHandler *handlers.AuthHandler, authSvc *service.AuthService, samlHandler *samlauth.Handler) {
auth := v1.Group("/auth")
auth.Post("/register", authHandler.Register)
auth.Post("/invite", middleware.AuthRequired(authSvc), middleware.PermissionRequired(authSvc, "user.create"), authHandler.Invite)
auth.Post("/invite/resend-set-password", middleware.AuthRequired(authSvc), middleware.PermissionRequired(authSvc, "user.update"), forgotPasswordIPLimiter(authSvc), authHandler.ResendSetPasswordInvite)
auth.Post("/set-password", authHandler.SetPassword)
auth.Post("/forgot-password", forgotPasswordIPLimiter(authSvc), authHandler.ForgotPassword)
auth.Post("/reset-password", authHandler.ResetPassword)
auth.Post("/pin/setup", middleware.AuthRequired(authSvc), authHandler.SetupSecurityPIN)
auth.Post("/pin/verify", middleware.AuthRequired(authSvc), authHandler.VerifySecurityPIN)
auth.Post("/pin/change", middleware.AuthRequired(authSvc), authHandler.ChangeSecurityPIN)
auth.Post("/pin/request-reset", middleware.AuthRequired(authSvc), forgotPasswordIPLimiter(authSvc), authHandler.RequestSecurityPINReset)
auth.Post("/pin/forgot", forgotPasswordIPLimiter(authSvc), authHandler.ForgotSecurityPIN)
auth.Post("/pin/reset", authHandler.ResetSecurityPIN)
auth.Post("/login", loginIPLimiter(authSvc), authHandler.Login)
auth.Post("/exchange", authHandler.Exchange)
auth.Post("/webauthn/register/begin", middleware.AuthRequired(authSvc), authHandler.WebAuthnRegisterBegin)
auth.Post("/webauthn/register/finish", middleware.AuthRequired(authSvc), authHandler.WebAuthnRegisterFinish)
auth.Post("/webauthn/login/begin", authHandler.WebAuthnLoginBegin)
auth.Post("/webauthn/login/finish", authHandler.WebAuthnLoginFinish)
auth.Post("/webauthn/reset-setup", middleware.AuthRequired(authSvc), authHandler.WebAuthnResetSetup)
auth.Post("/refresh", authHandler.Refresh)
auth.Post("/logout", authHandler.Logout)
auth.Get("/sessions", middleware.AuthRequired(authSvc), authHandler.Sessions)
auth.Delete("/sessions/:session_id", middleware.AuthRequired(authSvc), authHandler.DeleteSession)
auth.Get("/me", middleware.AuthRequired(authSvc), authHandler.Me)
auth.Patch("/me/timezone", middleware.AuthRequired(authSvc), authHandler.UpdateTimezone)
auth.Get("/verify-email", authHandler.VerifyEmail)
if samlHandler != nil {
auth.Get("/microsoft/login", samlHandler.HandleLogin)
auth.Get("/microsoft/metadata", samlHandler.HandleMetadata)
} else {
auth.Get("/microsoft/login", authHandler.MicrosoftLogin)
}
auth.Get("/microsoft/login-check", authHandler.MicrosoftLoginCheck)
auth.Get("/microsoft/link", middleware.AuthRequired(authSvc), authHandler.MicrosoftLink)
auth.Get("/microsoft/reauth", middleware.AuthRequired(authSvc), authHandler.MicrosoftReauth)
if samlHandler != nil {
auth.Get("/microsoft/callback", samlHandler.HandleSAMLCallback)
auth.Post("/microsoft/callback", samlHandler.HandleSAMLCallback)
auth.Get("/microsoft/logout", func(c *fiber.Ctx) error {
if c.Query("SAMLRequest") != "" {
return samlHandler.HandleSAMLLogoutRequest(c)
}
if c.Query("SAMLResponse") != "" {
return samlHandler.HandleSAMLLogoutResponse(c)
}
return samlHandler.HandleBrowserLocalLogout(c)
})
auth.Post("/microsoft/logout", func(c *fiber.Ctx) error {
if c.FormValue("SAMLRequest") != "" {
return samlHandler.HandleSAMLLogoutRequest(c)
}
if c.FormValue("SAMLResponse") != "" {
return samlHandler.HandleSAMLLogoutResponse(c)
}
return samlHandler.HandleBrowserLocalLogout(c)
})
} else {
auth.Get("/microsoft/callback", authHandler.MicrosoftCallback)
auth.Post("/microsoft/logout", middleware.AuthRequired(authSvc), authHandler.MicrosoftLogout)
}
// OIDC front-channel logout: invoked by Microsoft Entra in a hidden iframe when the
// user signs out at the IdP. Must be public (the IdP has no app credentials) and
// available regardless of the active Microsoft auth mode.
auth.Get("/microsoft/front-channel-logout", authHandler.MicrosoftFrontChannelLogout)
auth.Post("/microsoft/front-channel-logout", authHandler.MicrosoftFrontChannelLogout)
auth.Post("/microsoft/refresh", middleware.AuthRequired(authSvc), authHandler.MicrosoftRefresh)
auth.Post("/sso/link", middleware.AuthRequired(authSvc), authHandler.SSOLink)
auth.Delete("/sso/unlink", middleware.AuthRequired(authSvc), authHandler.SSOUnlink)
auth.Post("/totp/setup", middleware.AuthRequired(authSvc), authHandler.TOTPSetup)
auth.Post("/totp/confirm", middleware.AuthRequired(authSvc), authHandler.TOTPConfirm)
auth.Post("/totp/verify", authHandler.TOTPVerify)
auth.Post("/email-otp/send", authHandler.SendEmailOTP)
auth.Post("/email-otp/verify", authHandler.VerifyEmailOTP)
auth.Post("/totp/disable", middleware.AuthRequired(authSvc), authHandler.TOTPDisable)
auth.Post("/totp/reset-setup", middleware.AuthRequired(authSvc), authHandler.TOTPResetSetup)
}
func forgotPasswordIPLimiter(authSvc *service.AuthService) fiber.Handler {
window := authSvc.ForgotPasswordIPRateWindow()
return limiter.New(limiter.Config{
Max: authSvc.ForgotPasswordIPRateMax(),
Expiration: window,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
})
}
func loginIPLimiter(authSvc *service.AuthService) fiber.Handler {
window := authSvc.LoginIPRateWindow()
return limiter.New(limiter.Config{
Max: authSvc.LoginIPRateMax(),
Expiration: window,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
})
}

View File

@@ -0,0 +1,154 @@
package api
import (
"net/http"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/config"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
)
func TestForgotPasswordIPLimiter(t *testing.T) {
cfg := config.AuthConfig{
ForgotPasswordIPRateMax: 1,
ForgotPasswordIPRateWindow: time.Minute,
}
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
app := fiber.New()
app.Post("/forgot", forgotPasswordIPLimiter(svc), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req1, _ := http.NewRequest(http.MethodPost, "/forgot", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/forgot", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
}
}
func TestLoginIPLimiter(t *testing.T) {
cfg := config.AuthConfig{
LoginIPRateMax: 1,
LoginIPRateWindow: time.Minute,
}
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
app := fiber.New()
app.Post("/login", loginIPLimiter(svc), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req1, _ := http.NewRequest(http.MethodPost, "/login", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/login", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
}
}
func TestRegisterAuthRoutes(t *testing.T) {
app := fiber.New()
v1 := app.Group("/api/v1")
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
authHandler := handlers.NewAuthHandler(svc)
registerAuthRoutes(v1, authHandler, svc, nil)
routes := app.GetRoutes(true)
required := []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/v1/auth/register"},
{method: http.MethodPost, path: "/api/v1/auth/invite"},
{method: http.MethodPost, path: "/api/v1/auth/invite/resend-set-password"},
{method: http.MethodPost, path: "/api/v1/auth/forgot-password"},
{method: http.MethodPost, path: "/api/v1/auth/reset-password"},
{method: http.MethodPost, path: "/api/v1/auth/pin/setup"},
{method: http.MethodPost, path: "/api/v1/auth/pin/verify"},
{method: http.MethodPost, path: "/api/v1/auth/pin/change"},
{method: http.MethodPost, path: "/api/v1/auth/pin/request-reset"},
{method: http.MethodPost, path: "/api/v1/auth/pin/forgot"},
{method: http.MethodPost, path: "/api/v1/auth/pin/reset"},
{method: http.MethodPost, path: "/api/v1/auth/login"},
{method: http.MethodPost, path: "/api/v1/auth/exchange"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/register/begin"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/register/finish"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/login/begin"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/login/finish"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/reset-setup"},
{method: http.MethodPost, path: "/api/v1/auth/refresh"},
{method: http.MethodGet, path: "/api/v1/auth/sessions"},
{method: http.MethodDelete, path: "/api/v1/auth/sessions/:session_id"},
{method: http.MethodGet, path: "/api/v1/auth/me"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/login"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/login-check"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/link"},
{method: http.MethodPost, path: "/api/v1/auth/sso/link"},
{method: http.MethodDelete, path: "/api/v1/auth/sso/unlink"},
{method: http.MethodPost, path: "/api/v1/auth/totp/verify"},
{method: http.MethodPost, path: "/api/v1/auth/totp/reset-setup"},
}
for _, tc := range required {
if !hasRoute(routes, tc.method, tc.path) {
t.Fatalf("route %s %s not registered", tc.method, tc.path)
}
}
var forgotRoute *fiber.Route
for i := range routes {
if routes[i].Method == http.MethodPost && routes[i].Path == "/api/v1/auth/forgot-password" {
forgotRoute = &routes[i]
break
}
}
if forgotRoute == nil {
t.Fatalf("forgot password route not found")
}
if len(forgotRoute.Handlers) < 2 {
t.Fatalf("expected forgot password route to have limiter and handler, got %d handlers", len(forgotRoute.Handlers))
}
var loginRoute *fiber.Route
for i := range routes {
if routes[i].Method == http.MethodPost && routes[i].Path == "/api/v1/auth/login" {
loginRoute = &routes[i]
break
}
}
if loginRoute == nil {
t.Fatalf("login route not found")
}
if len(loginRoute.Handlers) < 2 {
t.Fatalf("expected login route to have limiter and handler, got %d handlers", len(loginRoute.Handlers))
}
}

View File

@@ -0,0 +1,35 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerBaseRoutes(
v1 fiber.Router,
baseHandler *handlers.BaseHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
bases := v1.Group("/bases", authMiddleware)
bases.Get("/events", middleware.PermissionRequired(authSvc, "base.read"), eventsHandler.Stream)
bases.Post("/create",
middleware.PermissionRequired(authSvc, "base.create"),
baseHandler.CreateBase,
)
bases.Get("/get-all", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.ListBases)
bases.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.ListBasesDatatable)
bases.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.GetBase)
bases.Patch("/update/:uuid",
middleware.PermissionRequired(authSvc, "base.update"),
baseHandler.UpdateBase,
)
bases.Delete("/delete/:uuid",
middleware.PermissionRequired(authSvc, "base.delete"),
baseHandler.DeleteBase,
)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerBrandingRoutes(v1 fiber.Router, brandingHandler *handlers.BrandingHandler) {
if brandingHandler == nil {
return
}
branding := v1.Group("/branding")
branding.Get("/logo", brandingHandler.GetLogo)
branding.Get("/login-cover", brandingHandler.GetLoginCover)
branding.Get("/login-content", brandingHandler.GetLoginContent)
publicBranding := v1.Group("/public/branding")
publicBranding.Get("/email-logo", brandingHandler.GetEmailLogo)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerComplaintRoutes(v1 fiber.Router, h *handlers.ComplaintHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
complaints := v1.Group("/complaints", authMiddleware)
complaints.Post("/create", middleware.PermissionRequired(authSvc, "complaint.create"), h.Create)
complaints.Get("/get-all", middleware.PermissionRequired(authSvc, "complaint.read"), h.List)
complaints.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListDatatable)
complaints.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListByHelicopter)
complaints.Get("/hold-items/get-all", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListHoldItems)
complaints.Get("/hold-items/get-all/dt", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListHoldItemsDatatable)
complaints.Get("/get/:id", middleware.PermissionRequired(authSvc, "complaint.read"), h.GetByID)
complaints.Patch("/update/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.Update)
complaints.Post("/action-taken/create/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.ActionTaken)
complaints.Patch("/action-taken/update/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.UpdateActionTaken)
complaints.Post("/nsr/sign/:id", middleware.PermissionRequired(authSvc, "complaint.nsr"), h.NSRSign)
complaints.Post("/nsr/unsign/:id", middleware.PermissionRequired(authSvc, "complaint.nsr"), h.NSRUnsign)
complaints.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "complaint.delete"), h.Delete)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerContactRoutes(v1 fiber.Router, contactHandler *handlers.ContactHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
contacts := v1.Group("/contacts", authMiddleware)
contacts.Post("/create", middleware.PermissionRequired(authSvc, "user.create"), contactHandler.Create)
contacts.Get("/get-all", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.List)
contacts.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.ListDatatable)
contacts.Get("/:user_id", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.Get)
contacts.Patch("/:user_id", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.Update)
contacts.Delete("/:user_id", middleware.PermissionRequired(authSvc, "user.delete"), contactHandler.Delete)
contacts.Patch("/:user_id/status", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateContactStatus)
contacts.Patch("/:user_id/role", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateContactRole)
contacts.Patch("/:user_id/pilot-category", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdatePilotCategory)
contacts.Patch("/:user_id/license", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateLicense)
contacts.Patch("/:user_id/chief-flags", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateChiefFlags)
contacts.Post("/bulk-update", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.BulkUpdate)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerDULRoutes(
v1 fiber.Router,
dulHandler *handlers.DULHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
dul := v1.Group("/dul", authMiddleware)
dul.Get("/events", middleware.PermissionRequired(authSvc, "dul.read"), eventsHandler.Stream)
dul.Post("/create", middleware.PermissionRequired(authSvc, "dul.create"), dulHandler.Create)
dul.Get("/get-all", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.List)
dul.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.ListDatatable)
dul.Get("/get/:id", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.Get)
dul.Patch("/update/:id", middleware.PermissionRequired(authSvc, "dul.update"), dulHandler.Update)
dul.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "dul.delete"), dulHandler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerDutyRosterRoutes(v1 fiber.Router, rosterHandler *handlers.DutyRosterHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
dutyRosters := v1.Group("/duty-roster", authMiddleware)
dutyRosters.Post("/create", middleware.PermissionRequired(authSvc, "duty_roster.create"), rosterHandler.CreateAny)
dutyRosters.Get("/get-all", middleware.PermissionRequired(authSvc, "duty_roster.read"), rosterHandler.GetAllRosters)
dutyRosters.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "duty_roster.read"), rosterHandler.ListAnyDatatable)
dutyRosters.Patch("/update", middleware.PermissionRequired(authSvc, "duty_roster.update"), rosterHandler.UpdateAny)
dutyRosters.Delete("/delete", middleware.PermissionRequired(authSvc, "duty_roster.delete"), rosterHandler.DeleteAny)
dutyRosters.Delete("/crew", middleware.PermissionRequired(authSvc, "duty_roster.delete"), rosterHandler.DeleteAnyCrew)
}

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerEASAReleaseRoutes(v1 fiber.Router, h *handlers.EASAReleaseHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/easa-releases", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "easa_release.create"), h.Create)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.ListByHelicopter)
g.Get("/get-by-flight/:flight_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByFlight)
g.Get("/get-by-complaint/:complaint_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByComplaint)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByID)
g.Patch("/update/:id", middleware.PermissionRequired(authSvc, "easa_release.update"), h.Update)
g.Post("/sign/:id", middleware.PermissionRequired(authSvc, "easa_release.sign"), h.Sign)
g.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "easa_release.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFacilityRoutes(v1 fiber.Router, facilityHandler *handlers.FacilityHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
facilities := v1.Group("/facilities", authMiddleware)
facilities.Post("/create", middleware.PermissionRequired(authSvc, "facility.create"), facilityHandler.Create)
facilities.Get("/get-all", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.List)
facilities.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.ListDatatable)
facilities.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.Get)
facilities.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "facility.update"), facilityHandler.Update)
facilities.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "facility.delete"), facilityHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFederalStateRoutes(v1 fiber.Router, federalStateHandler *handlers.FederalStateHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
federalState := v1.Group("/federal-state", authMiddleware)
federalState.Post("/create", middleware.PermissionRequired(authSvc, "federal_state.create"), federalStateHandler.Create)
federalState.Get("/get-all", middleware.PermissionRequired(authSvc, "federal_state.read"), federalStateHandler.List)
federalState.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "federal_state.read"), federalStateHandler.ListDatatable)
federalState.Patch("/update/:id", middleware.PermissionRequired(authSvc, "federal_state.update"), federalStateHandler.Update)
federalState.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "federal_state.delete"), federalStateHandler.Delete)
}

View File

@@ -0,0 +1,62 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFileManagerRoutes(
v1 fiber.Router,
folderHandler *handlers.FileManagerFolderHandler,
fileHandler *handlers.FileManagerFileHandler,
attachmentHandler *handlers.FileManagerAttachmentHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
fm := v1.Group("/file-manager", authMiddleware)
fm.Get("/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.ListAll)
fm.Get("/events", middleware.PermissionRequired(authSvc, "file_manager.read"), eventsHandler.Stream)
fm.Get("/trash/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.ListTrashItems)
fm.Patch("/move", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.MoveNodes)
fm.Delete("/delete", middleware.PermissionRequired(authSvc, "file_manager.delete"), folderHandler.DeleteNodesBulk)
fm.Patch("/restore", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.RestoreNodesBulk)
fm.Delete("/purge-delete", middleware.PermissionRequired(authSvc, "file_manager.delete"), folderHandler.PurgeNodesBulk)
folders := fm.Group("/folders")
folders.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), folderHandler.CreateFolder)
folders.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.GetFolder)
folders.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.UpdateFolder)
files := fm.Group("/files")
files.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFile)
files.Post("/create-from-template", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFromTemplate)
files.Post("/create-aircraft-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateAircraftImage)
files.Post("/create-base-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateBaseImage)
files.Post("/create-dul-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateDULImage)
files.Post("/create-user-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateUserImage)
files.Post("/create-takeover-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateTakeoverFile)
files.Get("/:fileId/edit-session", middleware.PermissionRequired(authSvc, "file_manager.read"), fileHandler.EditSession)
files.Post("/create-mission-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateMissionFile)
files.Post("/upload", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadFile)
files.Post("/upload-aircraft-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadAircraftImage)
files.Post("/upload-base-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadBaseImage)
files.Post("/upload-dul-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadDULImage)
files.Post("/upload-user-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadUserImage)
files.Post("/upload-takeover-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadTakeoverFile)
files.Post("/upload-mission-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadMissionFile)
files.Post("/upload-fleet-status-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadFleetStatusFile)
files.Post("/create-fleet-status-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFleetStatusFile)
files.Delete("/cancel-upload", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CancelUpload)
files.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), fileHandler.GetFile)
files.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "file_manager.update"), fileHandler.UpdateFile)
attachments := fm.Group("/attachments")
attachments.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), attachmentHandler.CreateAttachment)
attachments.Get("/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), attachmentHandler.ListAttachments)
attachments.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), attachmentHandler.GetAttachment)
attachments.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "file_manager.delete"), attachmentHandler.DeleteAttachment)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFleetStatusRoutes(v1 fiber.Router, h *handlers.FleetStatusHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/fleet-status", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), h.Create)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "flight.read"), h.GetByID)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "flight.read"), h.GetByHelicopterID)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), h.ListDatatable)
g.Get("/history/:helicopter_id", middleware.PermissionRequired(authSvc, "flight.read"), h.ListHistoryByHelicopter)
g.Post("/mark-serviced/:id", middleware.PermissionRequired(authSvc, "flight.update"), h.MarkServiced)
g.Patch("/update/:id", middleware.PermissionRequired(authSvc, "flight.update"), h.Update)
g.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "flight.delete"), h.Delete)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFlightRoutes(v1 fiber.Router, flightHandler *handlers.FlightHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
flights := v1.Group("/flights", authMiddleware)
flights.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightHandler.List)
flights.Get("/get", middleware.PermissionRequired(authSvc, "flight.read"), flightHandler.GetByAuthUser)
}

View File

@@ -0,0 +1,24 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFlightDataRoutes(v1 fiber.Router, flightDataHandler *handlers.FlightDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
flightData := v1.Group("/flight-data", authMiddleware)
flightData.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), flightDataHandler.Create)
flightData.Get("/get/:id", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.Get)
flightData.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.Get)
flightData.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.List)
flightData.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.ListDatatable)
flightData.Patch("/update/:id", middleware.PermissionRequired(authSvc, "flight.update"), flightDataHandler.Update)
flightData.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "flight.delete"), flightDataHandler.Delete)
flightsData := v1.Group("/flights-data", authMiddleware)
flightsData.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.List)
flightsData.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.ListDatatable)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFMReportRoutes(v1 fiber.Router, fmReportHandler *handlers.FMReportHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
fmReports := v1.Group("/fm-reports", authMiddleware)
fmReports.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), fmReportHandler.List)
fmReports.Get("/get/:flight_id", middleware.PermissionRequired(authSvc, "flight.read"), fmReportHandler.GetByFlightID)
fmReports.Patch("/update/:flight_id", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.UpdateByFlightID)
fmReports.Post("/complete/:flight_id", middleware.PermissionRequired(authSvc, "flight.complete"), fmReportHandler.Complete)
fmReports.Post("/:id/attachments/upload-intent", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.RequestAttachmentUploadIntent)
fmReports.Post("/:id/attachments", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.UploadAttachment)
fmReports.Delete("/:id/attachments/:att_id", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.DeleteAttachment)
fmReports.Delete("/:id", middleware.PermissionRequired(authSvc, "flight.delete"), middleware.PinRequired(authSvc, sharedconst.PinActionFmReportDelete), fmReportHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerForcesPresentRoutes(v1 fiber.Router, forcesPresentHandler *handlers.ForcesPresentHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
forcesPresent := v1.Group("/forces-present", authMiddleware)
forcesPresent.Post("/create", middleware.PermissionRequired(authSvc, "forces_present.create"), forcesPresentHandler.Create)
forcesPresent.Get("/get-all", middleware.PermissionRequired(authSvc, "forces_present.read"), forcesPresentHandler.List)
forcesPresent.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "forces_present.read"), forcesPresentHandler.ListDatatable)
forcesPresent.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "forces_present.update"), forcesPresentHandler.Update)
forcesPresent.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "forces_present.delete"), forcesPresentHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHealthInsuranceCompaniesRoutes(v1 fiber.Router, healthInsuranceCompaniesHandler *handlers.HealthInsuranceCompaniesHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
hic := v1.Group("/health-insurance-companies", authMiddleware)
hic.Post("/create", middleware.PermissionRequired(authSvc, "health_insurance_companies.create"), healthInsuranceCompaniesHandler.Create)
hic.Get("/get-all", middleware.PermissionRequired(authSvc, "health_insurance_companies.read"), healthInsuranceCompaniesHandler.List)
hic.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "health_insurance_companies.read"), healthInsuranceCompaniesHandler.ListDatatable)
hic.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "health_insurance_companies.update"), healthInsuranceCompaniesHandler.Update)
hic.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "health_insurance_companies.delete"), healthInsuranceCompaniesHandler.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterRoutes(v1 fiber.Router, helicopterHandler *handlers.HelicopterHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
helicopters := v1.Group("/helicopters", authMiddleware)
helicopters.Post("/create", middleware.PermissionRequired(authSvc, "helicopter.create"), helicopterHandler.Create)
helicopters.Post("/:id/reports/next-number", middleware.PermissionRequired(authSvc, "helicopter.report.generate"), helicopterHandler.NextReportNumber)
helicopters.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.List)
helicopters.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.ListDatatable)
helicopters.Get("/get/:id", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.Get)
helicopters.Patch("/update/:id", middleware.PermissionRequired(authSvc, "helicopter.update"), helicopterHandler.Update)
helicopters.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "helicopter.delete"), helicopterHandler.Delete)
}

View File

@@ -0,0 +1,27 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterFileRoutes(
v1 fiber.Router,
handler *handlers.HelicopterFileHandler,
eventsHandler *handlers.HelicopterFileEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
files := v1.Group("/helicopter-files", authMiddleware)
files.Get("/events", middleware.PermissionRequired(authSvc, "helicopter.read"), eventsHandler.Stream)
files.Post("/upload", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Upload)
files.Post("/cancel-upload", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.CancelUpload)
files.Post("/create", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Create)
files.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter.read"), handler.List)
files.Get("/get/:helicopter_id", middleware.PermissionRequired(authSvc, "helicopter.read"), handler.Get)
files.Patch("/update", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Update)
files.Delete("/delete", middleware.PermissionRequired(authSvc, "helicopter.delete"), handler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterUsageRoutes(v1 fiber.Router, helicopterUsageHandler *handlers.HelicopterUsageHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
helicopterUsage := v1.Group("/helicopter-usage", authMiddleware)
helicopterUsage.Post("/create", middleware.PermissionRequired(authSvc, "helicopter_usage.create"), helicopterUsageHandler.Create)
helicopterUsage.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.List)
helicopterUsage.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.ListDatatable)
helicopterUsage.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.Get)
helicopterUsage.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.update"), helicopterUsageHandler.Update)
helicopterUsage.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.delete"), helicopterUsageHandler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationRoutes(v1 fiber.Router, h *handlers.HEMSOperationHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operation", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "hems_operation.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationCategoryRoutes(v1 fiber.Router, h *handlers.HEMSOperationCategoryHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operation-category", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "operation_category.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "operation_category.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "operation_category.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "operation_category.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "operation_category.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "operation_category.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationalDataRoutes(v1 fiber.Router, h *handlers.HEMSOperationalDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operational-data", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "operation.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "operation.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "operation.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "operation.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "operation.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "operation.delete"), h.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHospitalRoutes(v1 fiber.Router, hospitalHandler *handlers.HospitalHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
hospital := v1.Group("/hospital", authMiddleware)
hospital.Post("/create", middleware.PermissionRequired(authSvc, "no_icao_code.create"), hospitalHandler.Create)
hospital.Get("/get-all", middleware.PermissionRequired(authSvc, "no_icao_code.read"), hospitalHandler.List)
hospital.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "no_icao_code.read"), hospitalHandler.ListDatatable)
hospital.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "no_icao_code.update"), hospitalHandler.Update)
hospital.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "no_icao_code.delete"), hospitalHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerICAORoutes(v1 fiber.Router, icaoHandler *handlers.ICAOHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
icao := v1.Group("/icao", authMiddleware)
icao.Post("/create", middleware.PermissionRequired(authSvc, "icao.create"), icaoHandler.Create)
icao.Get("/get-all", middleware.PermissionRequired(authSvc, "icao.read"), icaoHandler.List)
icao.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "icao.read"), icaoHandler.ListDatatable)
icao.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "icao.update"), icaoHandler.Update)
icao.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "icao.delete"), icaoHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerLandRoutes(v1 fiber.Router, landHandler *handlers.LandHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
land := v1.Group("/land", authMiddleware)
land.Post("/create", middleware.PermissionRequired(authSvc, "country.create"), landHandler.Create)
land.Get("/get-all", middleware.PermissionRequired(authSvc, "country.read"), landHandler.List)
land.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "country.read"), landHandler.ListDatatable)
land.Patch("/update/:id", middleware.PermissionRequired(authSvc, "country.update"), landHandler.Update)
land.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "country.delete"), landHandler.Delete)
}

View File

@@ -0,0 +1,17 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerLastFlightsInspectionRoutes(v1 fiber.Router, reserveAcHandler *handlers.ReserveAcHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
lastFlightsInspection := v1.Group("/after-flight-inspection", authMiddleware)
lastFlightsInspection.Get("/get", middleware.PermissionRequired(authSvc, "after_flight.read"), reserveAcHandler.GetLastFlightsInspection)
lastFlightsInspection.Post("/create", middleware.PermissionRequired(authSvc, "after_flight.create"), reserveAcHandler.CreateLastFlightsInspection)
lastFlightsInspection.Patch("/update", middleware.PermissionRequired(authSvc, "after_flight.update"), reserveAcHandler.UpdateLastFlightsInspection)
lastFlightsInspection.Delete("/delete", middleware.PermissionRequired(authSvc, "after_flight.delete"), reserveAcHandler.DeleteLastFlightsInspection)
}

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMasterSettingsRoutes(v1 fiber.Router, masterSettingsHandler *handlers.MasterSettingsHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
masterSettingsPublic := v1.Group("/master-settings")
masterSettingsPublic.Get("/get", masterSettingsHandler.Get)
masterSettings := v1.Group("/master-settings", authMiddleware)
masterSettings.Post("/create", middleware.PermissionRequired(authSvc, "master_settings.create"), masterSettingsHandler.Create)
masterSettings.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "master_settings.update"), masterSettingsHandler.Update)
masterSettings.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "master_settings.delete"), masterSettingsHandler.Delete)
masterSettings.Get("/sso/microsoft", middleware.PermissionRequired(authSvc, "master_settings.update"), masterSettingsHandler.GetMicrosoftEntraSSO)
masterSettings.Put("/sso/microsoft", middleware.PermissionRequired(authSvc, "master_settings.update"), masterSettingsHandler.UpsertMicrosoftEntraSSO)
}

View File

@@ -0,0 +1,17 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMCFRoutes(v1 fiber.Router, h *handlers.MCFHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/mcf", authMiddleware)
g.Post("/set/:helicopter_id", middleware.PermissionRequired(authSvc, "mcf.create"), h.Set)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "mcf.read"), h.ListByHelicopter)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "mcf.read"), h.GetByID)
g.Post("/sign/:id", middleware.PermissionRequired(authSvc, "mcf.complete"), h.Sign)
}

View File

@@ -0,0 +1,32 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMedicineRoutes(v1 fiber.Router, medicineHandler *handlers.MedicineHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
medicine := v1.Group("/medicine", authMiddleware)
medicine.Post("/create", middleware.PermissionRequired(authSvc, "medicine.create"), medicineHandler.Create)
medicine.Get("/get-all", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.List)
medicine.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListDatatable)
medicine.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "medicine.update"), medicineHandler.Update)
medicine.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "medicine.delete"), medicineHandler.Delete)
medicineGroup := v1.Group("/medicine-group", authMiddleware)
medicineGroup.Post("/create", middleware.PermissionRequired(authSvc, "medicine.create"), medicineHandler.CreateGroup)
medicineGroup.Get("/get-all", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListGroups)
medicineGroup.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListGroupsDatatable)
medicineGroup.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "medicine.update"), medicineHandler.UpdateGroup)
medicineGroup.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "medicine.delete"), medicineHandler.DeleteGroup)
motorReaction := v1.Group("/motor-reaction", authMiddleware)
motorReaction.Post("/create", middleware.PermissionRequired(authSvc, "medicine.create"), medicineHandler.CreateReaction)
motorReaction.Get("/get-all", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListReactions)
motorReaction.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListReactionsDatatable)
motorReaction.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "medicine.update"), medicineHandler.UpdateReaction)
motorReaction.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "medicine.delete"), medicineHandler.DeleteReaction)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMissionRoutes(v1 fiber.Router, missionHandler *handlers.MissionHandler, eventsHandler *handlers.FileManagerEventsHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
mission := v1.Group("/mission", authMiddleware)
mission.Get("/events", middleware.PermissionRequired(authSvc, "flight.read"), eventsHandler.Stream)
mission.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), missionHandler.Create)
mission.Patch("/update/:mission_id", middleware.PermissionRequired(authSvc, "flight.update"), missionHandler.Update)
mission.Delete("/file/:mission_id/:attachment_id", middleware.PermissionRequired(authSvc, "flight.update"), missionHandler.RemoveFile)
mission.Delete("/delete/:flight_id", middleware.PermissionRequired(authSvc, "flight.delete"), missionHandler.Delete)
mission.Delete("/delete-by-id/:mission_id", middleware.PermissionRequired(authSvc, "flight.delete"), missionHandler.DeleteByID)
mission.Get("/get/:flight_id", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.GetByFlightID)
mission.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.List)
mission.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.ListDatatable)
missionType := v1.Group("/mission-type", authMiddleware)
missionType.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), missionHandler.CreateType)
missionType.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.ListTypes)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerOpcRoutes(v1 fiber.Router, opcHandler *handlers.OpcHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
opc := v1.Group("/opc", authMiddleware)
opc.Post("/create", middleware.PermissionRequired(authSvc, "opc.create"), opcHandler.Create)
opc.Get("/get-all", middleware.PermissionRequired(authSvc, "opc.read"), opcHandler.List)
opc.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "opc.read"), opcHandler.ListDatatable)
opc.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "opc.update"), opcHandler.Update)
opc.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "opc.delete"), opcHandler.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
// registerOtherPersonRoutes exposes the reusable, NON-STAFF "other person" master under
// /contact/other. It is kept separate from /contacts (staff) and /takeovers on purpose.
func registerOtherPersonRoutes(v1 fiber.Router, otherPersonHandler *handlers.OtherPersonHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
other := v1.Group("/contact/other", authMiddleware)
other.Get("/", middleware.PermissionRequired(authSvc, "reserve_ac.read"), otherPersonHandler.Search)
other.Get("/get/:id", middleware.PermissionRequired(authSvc, "reserve_ac.read"), otherPersonHandler.Get)
other.Post("/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), otherPersonHandler.Create)
other.Patch("/update/:id", middleware.PermissionRequired(authSvc, "reserve_ac.update"), otherPersonHandler.Update)
other.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "reserve_ac.delete"), otherPersonHandler.Delete)
}

View File

@@ -0,0 +1,29 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerInsurancePatientDataRoutes(v1 fiber.Router, h *handlers.InsurancePatientDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/insurance-patient-data", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "insurance_patient_data.create"), h.Create)
g.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "insurance_patient_data.read"), h.Get)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "insurance_patient_data.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "insurance_patient_data.read"), h.ListDatatable)
g.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "insurance_patient_data.update"), h.Update)
g.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "insurance_patient_data.delete"), h.Delete)
}
func registerPatientDataRoutes(v1 fiber.Router, h *handlers.PatientDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/patient-data", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "patient_data.create"), h.Create)
g.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "patient_data.read"), h.Get)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "patient_data.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "patient_data.read"), h.ListDatatable)
g.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "patient_data.update"), h.Update)
g.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "patient_data.delete"), h.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerReserveAcRoutes(v1 fiber.Router, reserveAcHandler *handlers.ReserveAcHandler, takeoverHandler *handlers.TakeoverHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
reserveAcs := v1.Group("/reserve-acs", authMiddleware)
reserveAcs.Post("/takeovers/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), takeoverHandler.CreateTakeover)
reserveAcs.Post("/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), reserveAcHandler.Create)
reserveAcs.Get("/get-all", middleware.PermissionRequired(authSvc, "reserve_ac.read"), reserveAcHandler.List)
reserveAcs.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "reserve_ac.read"), reserveAcHandler.ListDatatable)
reserveAcs.Get("/get/:id", middleware.PermissionRequired(authSvc, "reserve_ac.read"), reserveAcHandler.Get)
reserveAcs.Patch("/update", middleware.PermissionRequired(authSvc, "reserve_ac.update"), reserveAcHandler.Update)
reserveAcs.Delete("/delete", middleware.PermissionRequired(authSvc, "reserve_ac.delete"), reserveAcHandler.Delete)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerRoleRoutes(v1 fiber.Router, roleHandler *handlers.RoleHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
roles := v1.Group("/roles", authMiddleware)
roles.Post("/create", middleware.PermissionRequired(authSvc, "role.create"), roleHandler.Create)
roles.Get("/get-all", middleware.PermissionRequired(authSvc, "role.read"), roleHandler.List)
roles.Get("/permissions/get-all", middleware.PermissionRequired(authSvc, "role.permission.read"), roleHandler.ListPermissions)
roles.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "role.read"), roleHandler.ListDatatable)
roles.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "role.read"), roleHandler.GetByID)
roles.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "role.update"), roleHandler.Update)
roles.Patch("/permissions/:permission_uuid", middleware.PermissionRequired(authSvc, "role.permission.update"), roleHandler.UpdatePermission)
roles.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "role.delete"), roleHandler.Delete)
roles.Get("/:uuid/permissions", middleware.PermissionRequired(authSvc, "role.permission.read"), roleHandler.ListRolePermissions)
roles.Post("/:uuid/permissions", middleware.PermissionRequired(authSvc, "role.permission.assign"), roleHandler.AssignPermission)
roles.Delete("/:uuid/permissions/:permission_uuid", middleware.PermissionRequired(authSvc, "role.permission.remove"), roleHandler.RemovePermission)
roles.Post("/:uuid/permissions/assign-bulk", middleware.PermissionRequired(authSvc, "role.permission.assign"), roleHandler.AssignPermissionsBulk)
roles.Post("/:uuid/permissions/remove-bulk", middleware.PermissionRequired(authSvc, "role.permission.remove"), roleHandler.RemovePermissionsBulk)
}

View File

@@ -0,0 +1,21 @@
package api
import "github.com/gofiber/fiber/v2"
func registerSAMLAuthRoutes(v1 fiber.Router, samlHandler interface {
HandleLogin(*fiber.Ctx) error
HandleSAMLCallback(*fiber.Ctx) error
HandleSLO(*fiber.Ctx) error
HandleMetadata(*fiber.Ctx) error
HandleLogout(*fiber.Ctx) error
}) {
if samlHandler == nil {
return
}
auth := v1.Group("/auth")
auth.Get("/login", samlHandler.HandleLogin)
auth.Post("/saml/callback", samlHandler.HandleSAMLCallback)
auth.Post("/saml/logout", samlHandler.HandleSLO)
auth.Get("/saml/metadata", samlHandler.HandleMetadata)
auth.Post("/saml/app-logout", samlHandler.HandleLogout)
}

View File

@@ -0,0 +1,10 @@
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/swagger"
)
func registerSwaggerRoutes(app *fiber.App) {
app.Get("/swagger/*", swagger.HandlerDefault)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerTakeoverRoutes(v1 fiber.Router, takeoverHandler *handlers.TakeoverHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
takeovers := v1.Group("/takeovers", authMiddleware)
takeovers.Post("/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), takeoverHandler.CreateTakeover)
takeovers.Get("/get-all", middleware.PermissionRequired(authSvc, "reserve_ac.read"), takeoverHandler.ListTakeovers)
takeovers.Get("/get/:id", middleware.PermissionRequired(authSvc, "reserve_ac.read"), takeoverHandler.GetTakeover)
takeovers.Patch("/update/:id", middleware.PermissionRequired(authSvc, "reserve_ac.update"), takeoverHandler.UpdateTakeover)
takeovers.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "reserve_ac.delete"), takeoverHandler.DeleteTakeover)
}

View File

@@ -0,0 +1,243 @@
package api
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestRegisterRoutes(t *testing.T) {
app := fiber.New()
RegisterRoutes(app, buildRouteDependenciesForTest())
routes := app.GetRoutes(true)
required := []struct {
method string
path string
}{
{method: http.MethodGet, path: "/health"},
{method: http.MethodGet, path: "/version"},
{method: http.MethodGet, path: "/api/version"},
{method: http.MethodGet, path: "/api/build-info"},
{method: http.MethodGet, path: "/metrics"},
{method: http.MethodPost, path: "/api/v1/auth/register"},
{method: http.MethodPost, path: "/api/v1/auth/forgot-password"},
{method: http.MethodPost, path: "/api/v1/users/create"},
{method: http.MethodGet, path: "/api/v1/users/events"},
{method: http.MethodPost, path: "/api/v1/contacts/create"},
{method: http.MethodGet, path: "/api/v1/contacts/get-all"},
{method: http.MethodGet, path: "/api/v1/contacts/get-all/dt"},
{method: http.MethodPost, path: "/api/v1/roles/create"},
{method: http.MethodPost, path: "/api/v1/helicopters/create"},
{method: http.MethodPost, path: "/api/v1/helicopter-usage/create"},
{method: http.MethodGet, path: "/api/v1/helicopter-usage/get-all"},
{method: http.MethodGet, path: "/api/v1/helicopter-usage/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/helicopter-usage/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/helicopter-usage/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/helicopter-usage/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/helicopters/:id/reports/next-number"},
{method: http.MethodPost, path: "/api/v1/helicopter-files/create"},
{method: http.MethodGet, path: "/api/v1/helicopter-files/events"},
{method: http.MethodPost, path: "/api/v1/helicopter-files/upload"},
{method: http.MethodPost, path: "/api/v1/helicopter-files/cancel-upload"},
{method: http.MethodGet, path: "/api/v1/helicopter-files/get-all"},
{method: http.MethodGet, path: "/api/v1/helicopter-files/get/:helicopter_id"},
{method: http.MethodPatch, path: "/api/v1/helicopter-files/update"},
{method: http.MethodDelete, path: "/api/v1/helicopter-files/delete"},
{method: http.MethodGet, path: "/api/v1/flights/get-all"},
{method: http.MethodGet, path: "/api/v1/flights/get"},
{method: http.MethodGet, path: "/api/v1/fm-reports/get-all"},
{method: http.MethodGet, path: "/api/v1/fm-reports/get/:flight_id"},
{method: http.MethodPatch, path: "/api/v1/fm-reports/update/:flight_id"},
{method: http.MethodPost, path: "/api/v1/fm-reports/complete/:flight_id"},
{method: http.MethodPost, path: "/api/v1/flight-data/create"},
{method: http.MethodGet, path: "/api/v1/flight-data/get/:uuid"},
{method: http.MethodGet, path: "/api/v1/flight-data/get-all"},
{method: http.MethodGet, path: "/api/v1/flight-data/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/flights-data/get-all"},
{method: http.MethodGet, path: "/api/v1/flights-data/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/mission-type/get-all"},
{method: http.MethodDelete, path: "/api/v1/mission/delete-by-id/:mission_id"},
{method: http.MethodPost, path: "/api/v1/reserve-acs/create"},
{method: http.MethodGet, path: "/api/v1/reserve-acs/get-all"},
{method: http.MethodGet, path: "/api/v1/reserve-acs/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/reserve-acs/get/:id"},
{method: http.MethodPatch, path: "/api/v1/reserve-acs/update"},
{method: http.MethodDelete, path: "/api/v1/reserve-acs/delete"},
{method: http.MethodGet, path: "/api/v1/after-flight-inspection/get"},
{method: http.MethodPost, path: "/api/v1/after-flight-inspection/create"},
{method: http.MethodPatch, path: "/api/v1/after-flight-inspection/update"},
{method: http.MethodDelete, path: "/api/v1/after-flight-inspection/delete"},
{method: http.MethodPost, path: "/api/v1/facilities/create"},
{method: http.MethodPost, path: "/api/v1/bases/create"},
{method: http.MethodGet, path: "/api/v1/bases/events"},
{method: http.MethodPost, path: "/api/v1/medicine/create"},
{method: http.MethodGet, path: "/api/v1/medicine/get-all"},
{method: http.MethodGet, path: "/api/v1/medicine/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/medicine/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/medicine/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/medicine-group/create"},
{method: http.MethodGet, path: "/api/v1/medicine-group/get-all"},
{method: http.MethodGet, path: "/api/v1/medicine-group/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/medicine-group/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/medicine-group/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/motor-reaction/create"},
{method: http.MethodGet, path: "/api/v1/motor-reaction/get-all"},
{method: http.MethodGet, path: "/api/v1/motor-reaction/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/motor-reaction/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/motor-reaction/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/vocation/create"},
{method: http.MethodGet, path: "/api/v1/vocation/get-all"},
{method: http.MethodGet, path: "/api/v1/vocation/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/vocation/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/vocation/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/opc/create"},
{method: http.MethodGet, path: "/api/v1/opc/get-all"},
{method: http.MethodGet, path: "/api/v1/opc/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/opc/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/opc/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/forces-present/create"},
{method: http.MethodGet, path: "/api/v1/forces-present/get-all"},
{method: http.MethodGet, path: "/api/v1/forces-present/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/forces-present/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/forces-present/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hospital/create"},
{method: http.MethodGet, path: "/api/v1/hospital/get-all"},
{method: http.MethodGet, path: "/api/v1/hospital/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/hospital/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hospital/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/health-insurance-companies/create"},
{method: http.MethodGet, path: "/api/v1/health-insurance-companies/get-all"},
{method: http.MethodGet, path: "/api/v1/health-insurance-companies/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/health-insurance-companies/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/health-insurance-companies/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/federal-state/create"},
{method: http.MethodGet, path: "/api/v1/federal-state/get-all"},
{method: http.MethodGet, path: "/api/v1/federal-state/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/federal-state/update/:id"},
{method: http.MethodDelete, path: "/api/v1/federal-state/delete/:id"},
{method: http.MethodPost, path: "/api/v1/icao/create"},
{method: http.MethodGet, path: "/api/v1/icao/get-all"},
{method: http.MethodGet, path: "/api/v1/icao/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/icao/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/icao/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/land/create"},
{method: http.MethodGet, path: "/api/v1/land/get-all"},
{method: http.MethodGet, path: "/api/v1/land/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/land/update/:id"},
{method: http.MethodDelete, path: "/api/v1/land/delete/:id"},
{method: http.MethodPost, path: "/api/v1/master-settings/create"},
{method: http.MethodGet, path: "/api/v1/master-settings/get"},
{method: http.MethodPatch, path: "/api/v1/master-settings/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/master-settings/delete/:uuid"},
{method: http.MethodGet, path: "/api/v1/branding/logo"},
{method: http.MethodGet, path: "/api/v1/branding/login-cover"},
{method: http.MethodGet, path: "/api/v1/branding/login-content"},
{method: http.MethodGet, path: "/api/v1/file-manager/get-all"},
{method: http.MethodGet, path: "/api/v1/file-manager/events"},
{method: http.MethodGet, path: "/api/v1/file-manager/trash/get-all"},
{method: http.MethodPatch, path: "/api/v1/file-manager/move"},
{method: http.MethodDelete, path: "/api/v1/file-manager/delete"},
{method: http.MethodPatch, path: "/api/v1/file-manager/restore"},
{method: http.MethodDelete, path: "/api/v1/file-manager/purge-delete"},
{method: http.MethodPost, path: "/api/v1/file-manager/folders/create"},
{method: http.MethodGet, path: "/api/v1/file-manager/folders/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/file-manager/folders/update/:uuid"},
{method: http.MethodPost, path: "/api/v1/file-manager/files/create"},
{method: http.MethodPost, path: "/api/v1/file-manager/files/upload"},
{method: http.MethodDelete, path: "/api/v1/file-manager/files/cancel-upload"},
{method: http.MethodGet, path: "/api/v1/file-manager/files/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/file-manager/files/update/:uuid"},
{method: http.MethodPost, path: "/api/v1/file-manager/attachments/create"},
{method: http.MethodGet, path: "/api/v1/file-manager/attachments/get-all"},
{method: http.MethodGet, path: "/api/v1/file-manager/attachments/get/:uuid"},
{method: http.MethodDelete, path: "/api/v1/file-manager/attachments/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/duty-roster/create"},
{method: http.MethodGet, path: "/api/v1/duty-roster/get-all"},
{method: http.MethodGet, path: "/api/v1/duty-roster/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/duty-roster/update"},
{method: http.MethodDelete, path: "/api/v1/duty-roster/delete"},
{method: http.MethodDelete, path: "/api/v1/duty-roster/crew"},
{method: http.MethodPost, path: "/api/v1/air-rescuer-checklist/create"},
{method: http.MethodGet, path: "/api/v1/air-rescuer-checklist/get-all"},
{method: http.MethodGet, path: "/api/v1/air-rescuer-checklist/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/air-rescuer-checklist/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/air-rescuer-checklist/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/air-rescuer-checklist/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/air-rescuer-checklist/:uuid/items/create"},
{method: http.MethodPatch, path: "/api/v1/air-rescuer-checklist/:uuid/items/reorder"},
{method: http.MethodPatch, path: "/api/v1/air-rescuer-checklist/items/update/:item_uuid"},
{method: http.MethodDelete, path: "/api/v1/air-rescuer-checklist/items/delete/:item_uuid"},
{method: http.MethodPost, path: "/api/v1/insurance-patient-data/create"},
{method: http.MethodGet, path: "/api/v1/insurance-patient-data/get/:uuid"},
{method: http.MethodGet, path: "/api/v1/insurance-patient-data/get-all"},
{method: http.MethodGet, path: "/api/v1/insurance-patient-data/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/insurance-patient-data/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/insurance-patient-data/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/patient-data/create"},
{method: http.MethodGet, path: "/api/v1/patient-data/get/:uuid"},
{method: http.MethodGet, path: "/api/v1/patient-data/get-all"},
{method: http.MethodGet, path: "/api/v1/patient-data/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/patient-data/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/patient-data/delete/:uuid"},
{method: http.MethodGet, path: "/api/v1/dul/events"},
{method: http.MethodPost, path: "/api/v1/hems-operational-data/create"},
{method: http.MethodGet, path: "/api/v1/hems-operational-data/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-operational-data/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-operational-data/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-operational-data/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-operational-data/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hems-operation-category/create"},
{method: http.MethodGet, path: "/api/v1/hems-operation-category/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-operation-category/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-operation-category/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-operation-category/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-operation-category/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hems-operation/create"},
{method: http.MethodGet, path: "/api/v1/hems-operation/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-operation/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-operation/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-operation/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-operation/delete/:uuid"},
{method: http.MethodGet, path: "/swagger/*"},
}
for _, tc := range required {
if !hasRoute(routes, tc.method, tc.path) {
t.Fatalf("route %s %s not registered", tc.method, tc.path)
}
}
legacy := []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/v1/crews/create"},
{method: http.MethodGet, path: "/api/v1/crews/get-all"},
{method: http.MethodGet, path: "/api/v1/crews/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/crews/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/crews/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/crews/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hems-bases/create"},
{method: http.MethodGet, path: "/api/v1/hems-bases/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-bases/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-bases/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-bases/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-bases/delete/:uuid"},
}
for _, tc := range legacy {
if hasRoute(routes, tc.method, tc.path) {
t.Fatalf("legacy route %s %s should not be registered", tc.method, tc.path)
}
}
}
func hasRoute(routes []fiber.Route, method, path string) bool {
for _, route := range routes {
if route.Method == method && route.Path == path {
return true
}
}
return false
}

View File

@@ -0,0 +1,58 @@
package api
import (
"wucher/internal/config"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
)
func buildRouteDependenciesForTest() RouteDependencies {
authSvc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
return RouteDependencies{
Handlers: RouteHandlers{
User: handlers.NewUserHandler(service.NewUserService(nil)),
Contact: handlers.NewContactHandler(service.NewContactService(nil, nil), nil),
Health: handlers.NewHealthHandler(),
BuildInfo: handlers.NewBuildInfoHandler(),
Auth: handlers.NewAuthHandler(authSvc),
Role: handlers.NewRoleHandler(service.NewRoleService(nil)),
Helicopter: handlers.NewHelicopterHandler(service.NewHelicopterService(nil)),
HelicopterUsage: handlers.NewHelicopterUsageHandler(service.NewHelicopterUsageService(nil)),
HelicopterFile: handlers.NewHelicopterFileHandler(service.NewHelicopterFileService(nil)),
Flight: handlers.NewFlightHandler(service.NewFlightService(nil)).WithMissionService(service.NewMissionService(nil)),
FMReport: handlers.NewFMReportHandler(service.NewFMReportService(nil)).WithTakeoverService(service.NewTakeoverService(nil)).WithHelicopterUsageService(service.NewHelicopterUsageService(nil)),
ReserveAc: handlers.NewReserveAcHandler(service.NewReserveAcService(nil)),
Facility: handlers.NewFacilityHandler(service.NewFacilityService(nil)),
Base: handlers.NewBaseHandler(service.NewBaseService(nil)),
Medicine: handlers.NewMedicineHandler(service.NewMedicineService(nil)),
Vocation: handlers.NewVocationHandler(service.NewVocationService(nil)),
Opc: handlers.NewOpcHandler(service.NewOpcService(nil)),
ForcesPresent: handlers.NewForcesPresentHandler(service.NewForcesPresentService(nil)),
Hospital: handlers.NewHospitalHandler(service.NewHospitalService(nil)),
HealthInsuranceCompanies: handlers.NewHealthInsuranceCompaniesHandler(service.NewHealthInsuranceCompaniesService(nil)),
FederalState: handlers.NewFederalStateHandler(service.NewFederalStateService(nil)),
ICAO: handlers.NewICAOHandler(service.NewICAOService(nil)),
Land: handlers.NewLandHandler(service.NewLandService(nil)),
MasterSettings: handlers.NewMasterSettingsHandler(service.NewMasterSettingsService(nil, service.MasterSettingsServiceDependencies{}), service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil),
Branding: handlers.NewBrandingHandler(service.NewMasterSettingsService(nil, service.MasterSettingsServiceDependencies{}), service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil),
FileManagerFolder: handlers.NewFileManagerFolderHandler(service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil),
FileManagerFile: handlers.NewFileManagerFileHandler(service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil).
WithAircraftService(service.NewHelicopterService(nil)).
WithBaseService(service.NewBaseService(nil)).
WithTakeoverService(service.NewTakeoverService(nil)),
FileManagerAttachment: handlers.NewFileManagerAttachmentHandler(service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{})),
FileManagerEvents: handlers.NewFileManagerEventsHandler(nil),
DutyRoster: handlers.NewDutyRosterHandler(service.NewDutyRosterService(nil)),
AirRescuerChecklist: handlers.NewAirRescuerChecklistHandler(service.NewAirRescuerChecklistService(nil)),
InsurancePatientData: handlers.NewInsurancePatientDataHandler(service.NewInsurancePatientDataService(nil)),
PatientData: handlers.NewPatientDataHandler(service.NewPatientDataService(nil)),
HEMSOperationalData: handlers.NewHEMSOperationalDataHandler(service.NewHEMSOperationalDataService(nil)),
HEMSOperationCategory: handlers.NewHEMSOperationCategoryHandler(service.NewHEMSOperationCategoryService(nil)),
HEMSOperation: handlers.NewHEMSOperationHandler(service.NewHEMSOperationService(nil)),
},
Services: RouteServices{
Auth: authSvc,
Audit: service.NewAuditLogService(nil, service.AuditLogServiceDependencies{QueueSize: 0, Workers: 0, IPEncryptionKey: ""}),
},
}
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerUserRoutes(
v1 fiber.Router,
userHandler *handlers.UserHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
users := v1.Group("/users", authMiddleware)
users.Get("/events", middleware.PermissionRequired(authSvc, "user.read"), eventsHandler.Stream)
users.Post("/create", middleware.PermissionRequired(authSvc, "user.create"), userHandler.Create)
users.Get("/get-all", middleware.PermissionRequired(authSvc, "user.read"), userHandler.List)
users.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "user.read"), userHandler.ListDatatable)
users.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "user.read"), userHandler.Get)
users.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "user.update"), userHandler.Update)
users.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "user.delete"), userHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerVocationRoutes(v1 fiber.Router, vocationHandler *handlers.VocationHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
vocation := v1.Group("/vocation", authMiddleware)
vocation.Post("/create", middleware.PermissionRequired(authSvc, "vocation.create"), vocationHandler.Create)
vocation.Get("/get-all", middleware.PermissionRequired(authSvc, "vocation.read"), vocationHandler.List)
vocation.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "vocation.read"), vocationHandler.ListDatatable)
vocation.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "vocation.update"), vocationHandler.Update)
vocation.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "vocation.delete"), vocationHandler.Delete)
}

View File

@@ -0,0 +1,23 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerWOPIRoutes(app fiber.Router, takeoverHandler *handlers.TakeoverHandler, wopiSvc *service.WOPIService) {
wopi := app.Group("/wopi", middleware.WOPIRequired(wopiSvc))
if wopiSvc != nil {
wopi.Use(middleware.WOPIRequestBodyLimit(wopiSvc.MaxBodyBytes()))
}
readOnly := wopi.Group("", middleware.WOPIPermissionRequired(service.WOPIPermissionRead))
writeOnly := wopi.Group("", middleware.WOPIPermissionRequired(service.WOPIPermissionWrite))
readOnly.Get("/files/:fileId", takeoverHandler.CheckWOPIFileInfo)
readOnly.Get("/files/:fileId/contents", takeoverHandler.GetWOPIContents)
writeOnly.Post("/files/:fileId/contents", takeoverHandler.PutWOPIContents)
writeOnly.Post("/files/:fileId", takeoverHandler.PostWOPIFile)
}

423
internal/app/api/seed.go Normal file
View File

@@ -0,0 +1,423 @@
package api
import (
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"wucher/internal/domain/auth"
"wucher/internal/domain/base"
)
// SeedRoles inserts default roles if they don't exist.
func SeedRoles(db *gorm.DB) error {
roles := []auth.Role{
{Code: "admin", Name: "admin", Description: "Full access to all resources."},
{Code: auth.RoleCodeStaff, Name: auth.RoleCodeStaff, Description: "Operational access with limited admin rights."},
{Code: "user", Name: "user", Description: "Regular end-user access."},
{Code: auth.RoleCodePilot, Name: "pilot", Description: "HEMS pilot contact role."},
{Code: auth.RoleCodeDoctor, Name: "doctor", Description: "HEMS doctor contact role."},
{Code: auth.RoleCodeAirRescuer, Name: "air_rescuer", Description: "HEMS air rescuer contact role."},
{Code: auth.RoleCodeTechnician, Name: "technician", Description: "HEMS technician contact role."},
{Code: auth.RoleCodeFlightAssistant, Name: "flight_assistant", Description: "HEMS flight assistant contact role."},
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"code", "description"}),
}).Create(&roles).Error
}
// DefaultPermissions returns the canonical catalog of permissions seeded into the system.
func DefaultPermissions() []auth.Permission {
return []auth.Permission{
{Name: "Create User", Key: "user.create", Description: "Create users"},
{Name: "Read User", Key: "user.read", Description: "Read users"},
{Name: "Update User", Key: "user.update", Description: "Update users"},
{Name: "Delete User", Key: "user.delete", Description: "Delete users"},
{Name: "Create Role", Key: "role.create", Description: "Create roles"},
{Name: "Read Role", Key: "role.read", Description: "Read roles"},
{Name: "Update Role", Key: "role.update", Description: "Update roles"},
{Name: "Delete Role", Key: "role.delete", Description: "Delete roles"},
{Name: "Read Permission", Key: "role.permission.read", Description: "Read permissions"},
{Name: "Assign Permission", Key: "role.permission.assign", Description: "Assign permissions to role"},
{Name: "Remove Permission", Key: "role.permission.remove", Description: "Remove permissions from role"},
{Name: "Update Permission", Key: "role.permission.update", Description: "Update permission configuration"},
{Name: "Read Filesystem", Key: "filesystem.read", Description: "Read filesystem structure"},
{Name: "Read File Manager", Key: "file_manager.read", Description: "Read file manager"},
{Name: "Create File Manager", Key: "file_manager.create", Description: "Create in file manager"},
{Name: "Update File Manager", Key: "file_manager.update", Description: "Update in file manager"},
{Name: "Delete File Manager", Key: "file_manager.delete", Description: "Delete in file manager"},
{Name: "Read Folders", Key: "file_manager.folder.read", Description: "Read folders"},
{Name: "Create Folders", Key: "file_manager.folder.create", Description: "Create folders"},
{Name: "Update Folders", Key: "file_manager.folder.update", Description: "Update folders"},
{Name: "Delete Folders", Key: "file_manager.folder.delete", Description: "Delete folders"},
{Name: "Read Files", Key: "file_manager.file.read", Description: "Read files"},
{Name: "Create Files", Key: "file_manager.file.create", Description: "Create files"},
{Name: "Update Files", Key: "file_manager.file.update", Description: "Update files"},
{Name: "Delete Files", Key: "file_manager.file.delete", Description: "Delete files"},
{Name: "Create Helicopter", Key: "helicopter.create", Description: "Create helicopter"},
{Name: "Generate Helicopter Report Number", Key: "helicopter.report.generate", Description: "Generate next helicopter report number"},
{Name: "Read Helicopter", Key: "helicopter.read", Description: "Read helicopter"},
{Name: "Update Helicopter", Key: "helicopter.update", Description: "Update helicopter"},
{Name: "Delete Helicopter", Key: "helicopter.delete", Description: "Delete helicopter"},
{Name: "Create Helicopter Usage", Key: "helicopter_usage.create", Description: "Create helicopter usage"},
{Name: "Read Helicopter Usage", Key: "helicopter_usage.read", Description: "Read helicopter usage"},
{Name: "Update Helicopter Usage", Key: "helicopter_usage.update", Description: "Update helicopter usage"},
{Name: "Delete Helicopter Usage", Key: "helicopter_usage.delete", Description: "Delete helicopter usage"},
{Name: "Create Flight Inspection", Key: "flight_inspection.create", Description: "Create flight inspection"},
{Name: "Read Flight Inspection", Key: "flight_inspection.read", Description: "Read flight inspection"},
{Name: "Update Flight Inspection", Key: "flight_inspection.update", Description: "Update flight inspection"},
{Name: "Create After Flight Inspection", Key: "after_flight.create", Description: "Create after flight inspection"},
{Name: "Read After Flight Inspection", Key: "after_flight.read", Description: "Read after flight inspection"},
{Name: "Update After Flight Inspection", Key: "after_flight.update", Description: "Update after flight inspection"},
{Name: "Delete After Flight Inspection", Key: "after_flight.delete", Description: "Delete after flight inspection"},
{Name: "Create Flight", Key: "flight.create", Description: "Create flight"},
{Name: "Read Flight", Key: "flight.read", Description: "Read flight"},
{Name: "Update Flight", Key: "flight.update", Description: "Update flight"},
{Name: "Complete Flight Report", Key: "flight.complete", Description: "Complete (finalize) flight report"},
{Name: "Delete Flight", Key: "flight.delete", Description: "Delete flight"},
{Name: "Create Reserve AC", Key: "reserve_ac.create", Description: "Create reserve AC"},
{Name: "Read Reserve AC", Key: "reserve_ac.read", Description: "Read reserve AC"},
{Name: "Update Reserve AC", Key: "reserve_ac.update", Description: "Update reserve AC"},
{Name: "Delete Reserve AC", Key: "reserve_ac.delete", Description: "Delete reserve AC"},
{Name: "Create Facility", Key: "facility.create", Description: "Create facility"},
{Name: "Read Facility", Key: "facility.read", Description: "Read facility"},
{Name: "Update Facility", Key: "facility.update", Description: "Update facility"},
{Name: "Delete Facility", Key: "facility.delete", Description: "Delete facility"},
{Name: "Create Base", Key: "base.create", Description: "Create base"},
{Name: "Read Base", Key: "base.read", Description: "Read base"},
{Name: "Update Base", Key: "base.update", Description: "Update base"},
{Name: "Delete Base", Key: "base.delete", Description: "Delete base"},
{Name: "Create HEMS Base", Key: "hems_base.create", Description: "Create HEMS base"},
{Name: "Read HEMS Base", Key: "hems_base.read", Description: "Read HEMS base"},
{Name: "Update HEMS Base", Key: "hems_base.update", Description: "Update HEMS base"},
{Name: "Delete HEMS Base", Key: "hems_base.delete", Description: "Delete HEMS base"},
{Name: "Create Medicine", Key: "medicine.create", Description: "Create medicine"},
{Name: "Read Medicine", Key: "medicine.read", Description: "Read medicine"},
{Name: "Update Medicine", Key: "medicine.update", Description: "Update medicine"},
{Name: "Delete Medicine", Key: "medicine.delete", Description: "Delete medicine"},
{Name: "Create Vocation", Key: "vocation.create", Description: "Create vocation"},
{Name: "Read Vocation", Key: "vocation.read", Description: "Read vocation"},
{Name: "Update Vocation", Key: "vocation.update", Description: "Update vocation"},
{Name: "Delete Vocation", Key: "vocation.delete", Description: "Delete vocation"},
{Name: "Create OPC", Key: "opc.create", Description: "Create opc"},
{Name: "Read OPC", Key: "opc.read", Description: "Read opc"},
{Name: "Update OPC", Key: "opc.update", Description: "Update opc"},
{Name: "Delete OPC", Key: "opc.delete", Description: "Delete opc"},
{Name: "Create Forces Present", Key: "forces_present.create", Description: "Create forces present"},
{Name: "Read Forces Present", Key: "forces_present.read", Description: "Read forces present"},
{Name: "Update Forces Present", Key: "forces_present.update", Description: "Update forces present"},
{Name: "Delete Forces Present", Key: "forces_present.delete", Description: "Delete forces present"},
{Name: "Create No ICAO Code", Key: "no_icao_code.create", Description: "Create no icao code"},
{Name: "Read No ICAO Code", Key: "no_icao_code.read", Description: "Read no icao code"},
{Name: "Update No ICAO Code", Key: "no_icao_code.update", Description: "Update no icao code"},
{Name: "Delete No ICAO Code", Key: "no_icao_code.delete", Description: "Delete no icao code"},
{Name: "Create Health Insurance Companies", Key: "health_insurance_companies.create", Description: "Create health insurance companies"},
{Name: "Read Health Insurance Companies", Key: "health_insurance_companies.read", Description: "Read health insurance companies"},
{Name: "Update Health Insurance Companies", Key: "health_insurance_companies.update", Description: "Update health insurance companies"},
{Name: "Delete Health Insurance Companies", Key: "health_insurance_companies.delete", Description: "Delete health insurance companies"},
{Name: "Create Federal State", Key: "federal_state.create", Description: "Create federal state"},
{Name: "Read Federal State", Key: "federal_state.read", Description: "Read federal state"},
{Name: "Update Federal State", Key: "federal_state.update", Description: "Update federal state"},
{Name: "Delete Federal State", Key: "federal_state.delete", Description: "Delete federal state"},
{Name: "Create ICAO", Key: "icao.create", Description: "Create ICAO"},
{Name: "Read ICAO", Key: "icao.read", Description: "Read ICAO"},
{Name: "Update ICAO", Key: "icao.update", Description: "Update ICAO"},
{Name: "Delete ICAO", Key: "icao.delete", Description: "Delete ICAO"},
{Name: "Create Country", Key: "country.create", Description: "Create country"},
{Name: "Read Country", Key: "country.read", Description: "Read country"},
{Name: "Update Country", Key: "country.update", Description: "Update country"},
{Name: "Delete Country", Key: "country.delete", Description: "Delete country"},
{Name: "Create Master Settings", Key: "master_settings.create", Description: "Create master settings"},
{Name: "Read Master Settings", Key: "master_settings.read", Description: "Read master settings"},
{Name: "Update Master Settings", Key: "master_settings.update", Description: "Update master settings"},
{Name: "Delete Master Settings", Key: "master_settings.delete", Description: "Delete master settings"},
{Name: "Create Duty Roster", Key: "duty_roster.create", Description: "Create duty roster"},
{Name: "Read Duty Roster", Key: "duty_roster.read", Description: "Read duty roster"},
{Name: "Update Duty Roster", Key: "duty_roster.update", Description: "Update duty roster"},
{Name: "Delete Duty Roster", Key: "duty_roster.delete", Description: "Delete duty roster"},
{Name: "Create Air Rescuer Checklist", Key: "air_rescuer_checklist.create", Description: "Create air rescuer checklist"},
{Name: "Read Air Rescuer Checklist", Key: "air_rescuer_checklist.read", Description: "Read air rescuer checklist"},
{Name: "Update Air Rescuer Checklist", Key: "air_rescuer_checklist.update", Description: "Update air rescuer checklist"},
{Name: "Delete Air Rescuer Checklist", Key: "air_rescuer_checklist.delete", Description: "Delete air rescuer checklist"},
{Name: "Create Flight Prep Check", Key: "flight_prep_check.create", Description: "Create flight prep check"},
{Name: "Read Flight Prep Check", Key: "flight_prep_check.read", Description: "Read flight prep check"},
{Name: "Update Flight Prep Check", Key: "flight_prep_check.update", Description: "Update flight prep check"},
{Name: "Read Audit Log", Key: "audit.read", Description: "Read audit logs"},
{Name: "Create Insurance Patient Data", Key: "insurance_patient_data.create", Description: "Create insurance patient data"},
{Name: "Read Insurance Patient Data", Key: "insurance_patient_data.read", Description: "Read insurance patient data"},
{Name: "Update Insurance Patient Data", Key: "insurance_patient_data.update", Description: "Update insurance patient data"},
{Name: "Delete Insurance Patient Data", Key: "insurance_patient_data.delete", Description: "Delete insurance patient data"},
{Name: "Create Patient Data", Key: "patient_data.create", Description: "Create patient data"},
{Name: "Read Patient Data", Key: "patient_data.read", Description: "Read patient data"},
{Name: "Update Patient Data", Key: "patient_data.update", Description: "Update patient data"},
{Name: "Delete Patient Data", Key: "patient_data.delete", Description: "Delete patient data"},
{Name: "Create Operation", Key: "operation.create", Description: "Create HEMS operational data"},
{Name: "Read Operation", Key: "operation.read", Description: "Read HEMS operational data"},
{Name: "Update Operation", Key: "operation.update", Description: "Update HEMS operational data"},
{Name: "Delete Operation", Key: "operation.delete", Description: "Delete HEMS operational data"},
{Name: "Create Operation Category", Key: "operation_category.create", Description: "Create HEMS operation category"},
{Name: "Read Operation Category", Key: "operation_category.read", Description: "Read HEMS operation category"},
{Name: "Update Operation Category", Key: "operation_category.update", Description: "Update HEMS operation category"},
{Name: "Delete Operation Category", Key: "operation_category.delete", Description: "Delete HEMS operation category"},
{Name: "Create HEMS Operation", Key: "hems_operation.create", Description: "Create HEMS operation"},
{Name: "Read HEMS Operation", Key: "hems_operation.read", Description: "Read HEMS operation"},
{Name: "Update HEMS Operation", Key: "hems_operation.update", Description: "Update HEMS operation"},
{Name: "Delete HEMS Operation", Key: "hems_operation.delete", Description: "Delete HEMS operation"},
{Name: "Create DUL", Key: "dul.create", Description: "Create DUL"},
{Name: "Read DUL", Key: "dul.read", Description: "Read DUL"},
{Name: "Update DUL", Key: "dul.update", Description: "Update DUL"},
{Name: "Delete DUL", Key: "dul.delete", Description: "Delete DUL"},
{Name: "Create Complaint", Key: "complaint.create", Description: "Create complaint"},
{Name: "Read Complaint", Key: "complaint.read", Description: "Read complaint"},
{Name: "Update Complaint", Key: "complaint.update", Description: "Update complaint"},
{Name: "Delete Complaint", Key: "complaint.delete", Description: "Delete complaint"},
{Name: "Decide Complaint NSR", Key: "complaint.nsr", Description: "Set or cancel complaint NSR (non-safety release)"},
{Name: "Create EASA Release", Key: "easa_release.create", Description: "Create EASA maintenance release"},
{Name: "Read EASA Release", Key: "easa_release.read", Description: "Read EASA maintenance release"},
{Name: "Update EASA Release", Key: "easa_release.update", Description: "Update EASA maintenance release"},
{Name: "Delete EASA Release", Key: "easa_release.delete", Description: "Delete EASA maintenance release"},
{Name: "Sign EASA Release", Key: "easa_release.sign", Description: "Sign EASA maintenance release (return to service)"},
{Name: "Read Action Sign-off", Key: "action_signoff.read", Description: "Read corrective-action sign-off"},
{Name: "Sign Action Sign-off", Key: "action_signoff.sign", Description: "Toggle (sign/unsign) corrective-action sign-off, per complaint or fleet-wide"},
{Name: "Create MCF", Key: "mcf.create", Description: "Create maintenance check flight"},
{Name: "Read MCF", Key: "mcf.read", Description: "Read maintenance check flight"},
{Name: "Complete MCF", Key: "mcf.complete", Description: "Complete (release) a helicopter's open maintenance check flight"},
}
}
// SeedPermissions inserts default permissions and keeps existing rows in sync.
func SeedPermissions(db *gorm.DB) error {
perms := DefaultPermissions()
for i := range perms {
perms[i].Module = auth.ModuleForKey(perms[i].Key)
perms[i].RequiresPIN = auth.DefaultPermissionRequiresPIN(perms[i].Key)
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "description", "module"}),
}).Create(&perms).Error
}
// SeedRolePermissions inserts default role-permission mappings and ensures admin has all permissions.
func SeedRolePermissions(db *gorm.DB) error {
type rolePerm struct {
RoleName string
PermKey string
}
mappings := []rolePerm{
// staff: read-only for users/roles/permissions
{RoleName: "staff", PermKey: "user.read"},
{RoleName: "staff", PermKey: "role.read"},
{RoleName: "staff", PermKey: "role.permission.read"},
{RoleName: "staff", PermKey: "helicopter.read"},
{RoleName: "staff", PermKey: "flight_inspection.read"},
{RoleName: "staff", PermKey: "flight.read"},
{RoleName: "staff", PermKey: "reserve_ac.read"},
{RoleName: "staff", PermKey: "facility.read"},
{RoleName: "staff", PermKey: "base.read"},
{RoleName: "staff", PermKey: "hems_base.read"},
{RoleName: "staff", PermKey: "medicine.read"},
{RoleName: "staff", PermKey: "vocation.read"},
{RoleName: "staff", PermKey: "opc.read"},
{RoleName: "staff", PermKey: "forces_present.read"},
{RoleName: "staff", PermKey: "no_icao_code.read"},
{RoleName: "staff", PermKey: "health_insurance_companies.read"},
{RoleName: "staff", PermKey: "federal_state.read"},
{RoleName: "staff", PermKey: "icao.read"},
{RoleName: "staff", PermKey: "country.read"},
{RoleName: "staff", PermKey: "master_settings.read"},
{RoleName: "staff", PermKey: "file_manager.read"},
{RoleName: "staff", PermKey: "duty_roster.read"},
{RoleName: "staff", PermKey: "air_rescuer_checklist.read"},
{RoleName: "staff", PermKey: "flight_prep_check.create"},
{RoleName: "staff", PermKey: "flight_prep_check.read"},
{RoleName: "staff", PermKey: "flight_prep_check.update"},
{RoleName: "staff", PermKey: "helicopter_usage.read"},
{RoleName: "staff", PermKey: "after_flight.read"},
{RoleName: "staff", PermKey: "audit.read"},
{RoleName: "staff", PermKey: "insurance_patient_data.read"},
{RoleName: "staff", PermKey: "patient_data.read"},
{RoleName: "staff", PermKey: "operation.read"},
{RoleName: "staff", PermKey: "operation_category.read"},
{RoleName: "staff", PermKey: "hems_operation.read"},
{RoleName: "pilot", PermKey: "flight.read"},
{RoleName: "pilot", PermKey: "duty_roster.read"},
}
if err := assignAllPermissionsToRole(db, "admin"); err != nil {
return err
}
for _, m := range mappings {
if err := assignPermissionToRole(db, m.RoleName, m.PermKey); err != nil {
return err
}
}
return nil
}
func assignAllPermissionsToRole(db *gorm.DB, roleName string) error {
var role auth.Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return err
}
var perms []auth.Permission
if err := db.Find(&perms).Error; err != nil {
return err
}
for _, perm := range perms {
rp := auth.RolePermission{RoleID: role.ID, PermissionID: perm.ID}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "role_id"}, {Name: "permission_id"}},
DoNothing: true,
}).Create(&rp).Error; err != nil {
return err
}
}
return nil
}
func assignPermissionToRole(db *gorm.DB, roleName, permKey string) error {
var role auth.Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return err
}
var perm auth.Permission
if err := db.Where("`key` = ?", permKey).First(&perm).Error; err != nil {
return err
}
rp := auth.RolePermission{RoleID: role.ID, PermissionID: perm.ID}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "role_id"}, {Name: "permission_id"}},
DoNothing: true,
}).Create(&rp).Error
}
// SeedBaseCategories inserts default base categories if they don't exist.
func SeedBaseCategories(db *gorm.DB) error {
categories := []base.BaseCategory{
{Key: base.CategoryKeyRegular, Name: "Regular Base"},
{Key: base.CategoryKeyHEMS, Name: "HEMS Base"},
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"name"}),
}).Create(&categories).Error
}
// SeedRosterUsers inserts sample users for duty roster assignment in development.
// This seed is idempotent by email and keeps role assignment synced via user_roles.
func SeedRosterUsers(db *gorm.DB) error {
type rosterSeed struct {
Email string
FirstName string
LastName string
MobilePhone string
RoleCode string
}
seeds := []rosterSeed{
{Email: "pilot.main@wucher.local", FirstName: "Pilot", LastName: "Main", MobilePhone: "+620001", RoleCode: auth.RoleCodePilot},
{Email: "pilot.additional@wucher.local", FirstName: "Pilot", LastName: "Additional", MobilePhone: "+620002", RoleCode: auth.RoleCodePilot},
{Email: "doctor.main@wucher.local", FirstName: "Doctor", LastName: "Main", MobilePhone: "+620003", RoleCode: auth.RoleCodeDoctor},
{Email: "rescuer.main@wucher.local", FirstName: "Rescuer", LastName: "Main", MobilePhone: "+620004", RoleCode: auth.RoleCodeAirRescuer},
{Email: "helper.main@wucher.local", FirstName: "Helper", LastName: "Main", MobilePhone: "+620005", RoleCode: auth.RoleCodeFlightAssistant},
}
return db.Transaction(func(tx *gorm.DB) error {
roleKeys := make([]string, 0, len(seeds))
seenRole := make(map[string]struct{}, len(seeds))
for i := range seeds {
k := strings.ToLower(strings.TrimSpace(seeds[i].RoleCode))
if k == "" {
continue
}
if _, ok := seenRole[k]; ok {
continue
}
seenRole[k] = struct{}{}
roleKeys = append(roleKeys, k)
}
var roles []auth.Role
if err := tx.Where("LOWER(code) IN ? OR LOWER(name) IN ?", roleKeys, roleKeys).Find(&roles).Error; err != nil {
return err
}
roleIDByCode := make(map[string][]byte, len(roles))
for i := range roles {
code := strings.ToLower(strings.TrimSpace(roles[i].Code))
if code == "" {
code = strings.ToLower(strings.TrimSpace(roles[i].Name))
}
if code == "" {
continue
}
roleIDByCode[code] = append([]byte(nil), roles[i].ID...)
}
now := time.Now().UTC()
for i := range seeds {
roleCode := strings.ToLower(strings.TrimSpace(seeds[i].RoleCode))
roleID := roleIDByCode[roleCode]
if len(roleID) != 16 {
continue
}
row := auth.User{
Email: strings.ToLower(strings.TrimSpace(seeds[i].Email)),
Username: nil,
FirstName: strings.TrimSpace(seeds[i].FirstName),
LastName: strings.TrimSpace(seeds[i].LastName),
MobilePhone: strings.TrimSpace(seeds[i].MobilePhone),
Timezone: "UTC",
RoleID: roleID,
IsActive: true,
EmailVerifiedAt: &now,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.Assignments(map[string]any{
"first_name": row.FirstName,
"last_name": row.LastName,
"mobile_phone": row.MobilePhone,
"timezone": row.Timezone,
"role_id": row.RoleID,
"is_active": row.IsActive,
"email_verified_at": row.EmailVerifiedAt,
"updated_at": now,
}),
}).Create(&row).Error; err != nil {
return err
}
var created auth.User
if err := tx.Where("email = ?", row.Email).Take(&created).Error; err != nil {
return err
}
link := auth.UserRole{
UserID: created.ID,
RoleID: roleID,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "role_id"}},
DoNothing: true,
}).Create(&link).Error; err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,34 @@
package api
import (
"testing"
"wucher/internal/domain/auth"
)
// TestDefaultPermissionsHaveRegisteredModule guards that every seeded permission
// maps to a module declared in auth.ModuleRegistry. Adding a permission with a
// new prefix without registering its module will fail this test.
func TestDefaultPermissionsHaveRegisteredModule(t *testing.T) {
for _, p := range DefaultPermissions() {
module := auth.ModuleForKey(p.Key)
if !auth.IsRegisteredModule(module) {
t.Errorf("permission %q maps to module %q which is not in auth.ModuleRegistry", p.Key, module)
}
}
}
// TestDefaultPermissionsHaveUniqueKeys guards against accidental duplicate keys
// in the catalog (the upsert is keyed on "key").
func TestDefaultPermissionsHaveUniqueKeys(t *testing.T) {
seen := make(map[string]bool)
for _, p := range DefaultPermissions() {
if p.Key == "" {
t.Errorf("permission %q has an empty key", p.Name)
}
if seen[p.Key] {
t.Errorf("duplicate permission key %q in DefaultPermissions", p.Key)
}
seen[p.Key] = true
}
}

View File

@@ -0,0 +1,54 @@
package api
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
)
type Server struct {
app *fiber.App
shutdownTimeout time.Duration
}
type ServerOption func(*Server)
const defaultShutdownTimeout = 30 * time.Second
func WithShutdownTimeout(timeout time.Duration) ServerOption {
return func(s *Server) {
if timeout > 0 {
s.shutdownTimeout = timeout
}
}
}
func NewServer(app *fiber.App, opts ...ServerOption) *Server {
srv := &Server{
app: app,
shutdownTimeout: defaultShutdownTimeout,
}
for _, opt := range opts {
if opt != nil {
opt(srv)
}
}
return srv
}
func (s *Server) Start(addr string) error {
return s.app.Listen(addr)
}
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if s.shutdownTimeout > 0 {
shutdownCtx, cancel := context.WithTimeout(ctx, s.shutdownTimeout)
defer cancel()
ctx = shutdownCtx
}
return s.app.ShutdownWithContext(ctx)
}

View File

@@ -0,0 +1,160 @@
package api
import (
"context"
"errors"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/gofiber/fiber/v2"
)
func TestServerShutdownDrainsInFlightRequestsAndRejectsNewRequests(t *testing.T) {
app := fiber.New(fiber.Config{
DisableStartupMessage: true,
})
UseDefaultMiddlewares(app, testHTTPConfig())
slowStarted := make(chan struct{})
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-slowStarted:
default:
close(slowStarted)
}
select {
case <-time.After(300 * time.Millisecond):
case <-c.UserContext().Done():
return c.Status(http.StatusInternalServerError).SendString("user context canceled")
}
return c.SendString("slow done")
})
app.Get("/fast", func(c *fiber.Ctx) error {
return c.SendString("fast done")
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
serveErrCh := make(chan error, 1)
go func() {
serveErrCh <- app.Listener(ln)
}()
t.Cleanup(func() {
_ = app.Shutdown()
})
baseURL := "http://" + ln.Addr().String()
fastClient := newTestHTTPClient()
slowClient := newTestHTTPClient()
defer fastClient.CloseIdleConnections()
defer slowClient.CloseIdleConnections()
if err := waitForHTTP200(fastClient, baseURL+"/fast", time.Second); err != nil {
t.Fatalf("wait for server ready: %v", err)
}
slowRespCh := make(chan httpCallResult, 1)
go func() {
resp, err := slowClient.Get(baseURL + "/slow")
if err != nil {
slowRespCh <- httpCallResult{err: err}
return
}
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
slowRespCh <- httpCallResult{
statusCode: resp.StatusCode,
body: string(body),
err: readErr,
}
}()
select {
case <-slowStarted:
case <-time.After(time.Second):
t.Fatal("slow request did not start")
}
srv := NewServer(app, WithShutdownTimeout(2*time.Second))
shutdownErrCh := make(chan error, 1)
go func() {
shutdownErrCh <- srv.Shutdown(context.Background())
}()
if err := waitForRequestRejection(fastClient, baseURL+"/fast", time.Second); err == nil {
t.Fatal("expected new requests to be rejected during shutdown")
}
slowResult := <-slowRespCh
if slowResult.err != nil {
t.Fatalf("slow request failed: %v", slowResult.err)
}
if slowResult.statusCode != http.StatusOK {
t.Fatalf("expected slow request 200, got %d with body %q", slowResult.statusCode, slowResult.body)
}
if slowResult.body != "slow done" {
t.Fatalf("expected slow request body %q, got %q", "slow done", slowResult.body)
}
if err := <-shutdownErrCh; err != nil {
t.Fatalf("shutdown failed: %v", err)
}
if err := <-serveErrCh; err != nil {
t.Fatalf("listener exited with error: %v", err)
}
}
type httpCallResult struct {
statusCode int
body string
err error
}
func newTestHTTPClient() *http.Client {
return &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
}
func waitForHTTP200(client *http.Client, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(10 * time.Millisecond)
}
return errors.New("server did not become ready")
}
func waitForRequestRejection(client *http.Client, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
return err
}
lastErr = errors.New("request still accepted")
resp.Body.Close()
time.Sleep(10 * time.Millisecond)
}
return lastErr
}