init push
This commit is contained in:
219
internal/app/api/audit_middleware.go
Normal file
219
internal/app/api/audit_middleware.go
Normal 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
|
||||
}
|
||||
208
internal/app/api/audit_middleware_test.go
Normal file
208
internal/app/api/audit_middleware_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
internal/app/api/compress_wopi_test.go
Normal file
131
internal/app/api/compress_wopi_test.go
Normal 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()
|
||||
}
|
||||
471
internal/app/api/middleware.go
Normal file
471
internal/app/api/middleware.go
Normal 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
|
||||
}
|
||||
586
internal/app/api/middleware_test.go
Normal file
586
internal/app/api/middleware_test.go
Normal 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()
|
||||
}
|
||||
9
internal/app/api/pin_actions.go
Normal file
9
internal/app/api/pin_actions.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package api
|
||||
|
||||
type PinProtectedEndpoint struct {
|
||||
Method string
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
var PinProtectedEndpoints = []PinProtectedEndpoint{}
|
||||
64
internal/app/api/pin_actions_test.go
Normal file
64
internal/app/api/pin_actions_test.go
Normal 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
140
internal/app/api/routes.go
Normal 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)
|
||||
}
|
||||
15
internal/app/api/routes_action_signoff.go
Normal file
15
internal/app/api/routes_action_signoff.go
Normal 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)
|
||||
}
|
||||
23
internal/app/api/routes_air_rescuer_checklist.go
Normal file
23
internal/app/api/routes_air_rescuer_checklist.go
Normal 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)
|
||||
}
|
||||
14
internal/app/api/routes_audit.go
Normal file
14
internal/app/api/routes_audit.go
Normal 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)
|
||||
}
|
||||
142
internal/app/api/routes_auth.go
Normal file
142
internal/app/api/routes_auth.go
Normal 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",
|
||||
}},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
154
internal/app/api/routes_auth_test.go
Normal file
154
internal/app/api/routes_auth_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
35
internal/app/api/routes_base.go
Normal file
35
internal/app/api/routes_base.go
Normal 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,
|
||||
)
|
||||
}
|
||||
20
internal/app/api/routes_branding.go
Normal file
20
internal/app/api/routes_branding.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_complaint.go
Normal file
26
internal/app/api/routes_complaint.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_contacts.go
Normal file
26
internal/app/api/routes_contacts.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_dul.go
Normal file
26
internal/app/api/routes_dul.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_duty_roster.go
Normal file
19
internal/app/api/routes_duty_roster.go
Normal 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)
|
||||
}
|
||||
21
internal/app/api/routes_easa_release.go
Normal file
21
internal/app/api/routes_easa_release.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_facility.go
Normal file
19
internal/app/api/routes_facility.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_federal_state.go
Normal file
18
internal/app/api/routes_federal_state.go
Normal 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)
|
||||
}
|
||||
62
internal/app/api/routes_file_manager.go
Normal file
62
internal/app/api/routes_file_manager.go
Normal 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)
|
||||
}
|
||||
22
internal/app/api/routes_fleet_status.go
Normal file
22
internal/app/api/routes_fleet_status.go
Normal 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)
|
||||
}
|
||||
15
internal/app/api/routes_flight.go
Normal file
15
internal/app/api/routes_flight.go
Normal 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)
|
||||
}
|
||||
24
internal/app/api/routes_flight_data.go
Normal file
24
internal/app/api/routes_flight_data.go
Normal 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)
|
||||
}
|
||||
22
internal/app/api/routes_fm_report.go
Normal file
22
internal/app/api/routes_fm_report.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_forces_present.go
Normal file
18
internal/app/api/routes_forces_present.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_health_insurance_companies.go
Normal file
18
internal/app/api/routes_health_insurance_companies.go
Normal 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)
|
||||
}
|
||||
20
internal/app/api/routes_helicopter.go
Normal file
20
internal/app/api/routes_helicopter.go
Normal 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)
|
||||
}
|
||||
27
internal/app/api/routes_helicopter_file.go
Normal file
27
internal/app/api/routes_helicopter_file.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_helicopter_usage.go
Normal file
19
internal/app/api/routes_helicopter_usage.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_hems_operation.go
Normal file
19
internal/app/api/routes_hems_operation.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_hems_operation_category.go
Normal file
19
internal/app/api/routes_hems_operation_category.go
Normal 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)
|
||||
}
|
||||
19
internal/app/api/routes_hems_operational_data.go
Normal file
19
internal/app/api/routes_hems_operational_data.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_hospital.go
Normal file
18
internal/app/api/routes_hospital.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_icao.go
Normal file
18
internal/app/api/routes_icao.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_land.go
Normal file
18
internal/app/api/routes_land.go
Normal 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)
|
||||
}
|
||||
17
internal/app/api/routes_last_flights_inspection.go
Normal file
17
internal/app/api/routes_last_flights_inspection.go
Normal 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)
|
||||
}
|
||||
21
internal/app/api/routes_master_settings.go
Normal file
21
internal/app/api/routes_master_settings.go
Normal 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)
|
||||
}
|
||||
17
internal/app/api/routes_mcf.go
Normal file
17
internal/app/api/routes_mcf.go
Normal 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)
|
||||
}
|
||||
32
internal/app/api/routes_medicine.go
Normal file
32
internal/app/api/routes_medicine.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_mission.go
Normal file
26
internal/app/api/routes_mission.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_opc.go
Normal file
18
internal/app/api/routes_opc.go
Normal 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)
|
||||
}
|
||||
20
internal/app/api/routes_other_person.go
Normal file
20
internal/app/api/routes_other_person.go
Normal 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)
|
||||
}
|
||||
29
internal/app/api/routes_patient_data.go
Normal file
29
internal/app/api/routes_patient_data.go
Normal 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)
|
||||
}
|
||||
20
internal/app/api/routes_reserve_ac.go
Normal file
20
internal/app/api/routes_reserve_ac.go
Normal 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)
|
||||
}
|
||||
26
internal/app/api/routes_role.go
Normal file
26
internal/app/api/routes_role.go
Normal 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)
|
||||
}
|
||||
21
internal/app/api/routes_saml_auth.go
Normal file
21
internal/app/api/routes_saml_auth.go
Normal 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)
|
||||
}
|
||||
10
internal/app/api/routes_swagger.go
Normal file
10
internal/app/api/routes_swagger.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_takeover.go
Normal file
18
internal/app/api/routes_takeover.go
Normal 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)
|
||||
}
|
||||
243
internal/app/api/routes_test.go
Normal file
243
internal/app/api/routes_test.go
Normal 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
|
||||
}
|
||||
58
internal/app/api/routes_test_helpers_test.go
Normal file
58
internal/app/api/routes_test_helpers_test.go
Normal 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: ""}),
|
||||
},
|
||||
}
|
||||
}
|
||||
26
internal/app/api/routes_user.go
Normal file
26
internal/app/api/routes_user.go
Normal 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)
|
||||
}
|
||||
18
internal/app/api/routes_vocation.go
Normal file
18
internal/app/api/routes_vocation.go
Normal 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)
|
||||
}
|
||||
23
internal/app/api/routes_wopi.go
Normal file
23
internal/app/api/routes_wopi.go
Normal 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
423
internal/app/api/seed.go
Normal 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
|
||||
})
|
||||
}
|
||||
34
internal/app/api/seed_permissions_test.go
Normal file
34
internal/app/api/seed_permissions_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
54
internal/app/api/server.go
Normal file
54
internal/app/api/server.go
Normal 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)
|
||||
}
|
||||
160
internal/app/api/server_test.go
Normal file
160
internal/app/api/server_test.go
Normal 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
|
||||
}
|
||||
138
internal/app/queueing/builders.go
Normal file
138
internal/app/queueing/builders.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package queueing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/queue"
|
||||
mysqlrepo "wucher/internal/repository/mysql"
|
||||
sqsrepo "wucher/internal/repository/sqs"
|
||||
"wucher/internal/resilience"
|
||||
)
|
||||
|
||||
func NewEmailProducer(
|
||||
ctx context.Context,
|
||||
queueCfg config.QueueConfig,
|
||||
sqsCfg config.SQSConfig,
|
||||
executor *resilience.Executor,
|
||||
logger *slog.Logger,
|
||||
) (queue.EmailJobProducer, error) {
|
||||
producer, err := NewQueueProducer(ctx, sqsCfg, executor, "api")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queue.NewEmailEnqueuer(NewOutboxSerializer(queueCfg, sqsCfg), producer, logger), nil
|
||||
}
|
||||
|
||||
func NewQueueProducer(ctx context.Context, sqsCfg config.SQSConfig, executor *resilience.Executor, component string) (queue.QueueProducer, error) {
|
||||
client, err := sqsrepo.NewClient(ctx, sqsCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sqsrepo.NewQueue(
|
||||
sqsrepo.NewResilientAPI(client, executor),
|
||||
sqsCfg,
|
||||
sqsrepo.WithMetricsComponent(component),
|
||||
)
|
||||
}
|
||||
|
||||
func NewOutboxSerializer(queueCfg config.QueueConfig, sqsCfg config.SQSConfig) queue.MessageSerializer {
|
||||
return queue.NewJSONSerializer(
|
||||
queueCfg.MessageSchemaVersion,
|
||||
queueCfg.EnableLegacyPayloadFallback,
|
||||
sqsCfg.QueueType,
|
||||
sqsCfg.MessageGroupID,
|
||||
)
|
||||
}
|
||||
|
||||
func NewEmailConsumer(
|
||||
ctx context.Context,
|
||||
queueCfg config.QueueConfig,
|
||||
sqsCfg config.SQSConfig,
|
||||
executor *resilience.Executor,
|
||||
) (queue.QueueConsumer, queue.MessageSerializer, error) {
|
||||
client, err := sqsrepo.NewClient(ctx, sqsCfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
consumer, err := sqsrepo.NewEmailQueue(
|
||||
sqsrepo.NewResilientAPI(client, executor),
|
||||
sqsCfg,
|
||||
sqsrepo.WithMetricsComponent("email_worker"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
serializer := queue.NewJSONSerializer(
|
||||
queueCfg.MessageSchemaVersion,
|
||||
queueCfg.EnableLegacyPayloadFallback,
|
||||
sqsCfg.QueueType,
|
||||
sqsCfg.MessageGroupID,
|
||||
)
|
||||
return consumer, serializer, nil
|
||||
}
|
||||
|
||||
func NewFileProcessingProducer(
|
||||
ctx context.Context,
|
||||
queueCfg config.FileQueueConfig,
|
||||
sqsCfg config.SQSConfig,
|
||||
executor *resilience.Executor,
|
||||
logger *slog.Logger,
|
||||
) (queue.FileProcessingJobProducer, error) {
|
||||
producer, err := NewQueueProducer(ctx, sqsCfg, executor, "api")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queue.NewFileProcessingEnqueuer(NewFileProcessingSerializer(queueCfg, sqsCfg), producer, logger), nil
|
||||
}
|
||||
|
||||
func NewFileProcessingSerializer(queueCfg config.FileQueueConfig, sqsCfg config.SQSConfig) queue.FileProcessingMessageSerializer {
|
||||
return queue.NewFileProcessingJSONSerializer(
|
||||
queueCfg.MessageSchemaVersion,
|
||||
sqsCfg.QueueType,
|
||||
sqsCfg.MessageGroupID,
|
||||
)
|
||||
}
|
||||
|
||||
func NewFileProcessingConsumer(
|
||||
ctx context.Context,
|
||||
queueCfg config.FileQueueConfig,
|
||||
sqsCfg config.SQSConfig,
|
||||
executor *resilience.Executor,
|
||||
) (queue.QueueConsumer, queue.FileProcessingMessageSerializer, error) {
|
||||
client, err := sqsrepo.NewClient(ctx, sqsCfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
consumer, err := sqsrepo.NewQueue(
|
||||
sqsrepo.NewResilientAPI(client, executor),
|
||||
sqsCfg,
|
||||
sqsrepo.WithMetricsComponent("file_worker"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
serializer := queue.NewFileProcessingJSONSerializer(
|
||||
queueCfg.MessageSchemaVersion,
|
||||
sqsCfg.QueueType,
|
||||
sqsCfg.MessageGroupID,
|
||||
)
|
||||
return consumer, serializer, nil
|
||||
}
|
||||
|
||||
func NewIdempotencyStore(db *gorm.DB, queueCfg config.QueueConfig) queue.IdempotencyStore {
|
||||
if db == nil {
|
||||
return queue.NewMemoryIdempotencyStore()
|
||||
}
|
||||
return mysqlrepo.NewQueueIdempotencyStore(db, queueCfg.IdempotencyKeyPrefix)
|
||||
}
|
||||
|
||||
func NewIdempotencyStoreWithPrefix(db *gorm.DB, keyPrefix string) queue.IdempotencyStore {
|
||||
if db == nil {
|
||||
return queue.NewMemoryIdempotencyStore()
|
||||
}
|
||||
return mysqlrepo.NewQueueIdempotencyStore(db, keyPrefix)
|
||||
}
|
||||
241
internal/app/worker/file_processing_outbox_dispatcher.go
Normal file
241
internal/app/worker/file_processing_outbox_dispatcher.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type FileProcessingOutboxDispatcher struct {
|
||||
repo filemanager.FileProcessingOutboxRepository
|
||||
producer queue.QueueProducer
|
||||
logger *slog.Logger
|
||||
retryPolicy queue.RetryPolicy
|
||||
pollInterval time.Duration
|
||||
dispatchTimeout time.Duration
|
||||
lockTTL time.Duration
|
||||
maxAttempts int
|
||||
batchSize int
|
||||
workerID string
|
||||
metrics *outboxMetrics
|
||||
}
|
||||
|
||||
func NewFileProcessingOutboxDispatcher(
|
||||
cfg config.FileQueueConfig,
|
||||
repo filemanager.FileProcessingOutboxRepository,
|
||||
producer queue.QueueProducer,
|
||||
logger *slog.Logger,
|
||||
) *FileProcessingOutboxDispatcher {
|
||||
if !cfg.OutboxEnabled || repo == nil || producer == nil {
|
||||
return nil
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
workerID := fmt.Sprintf("%s-%d", hostname(), os.Getpid())
|
||||
return &FileProcessingOutboxDispatcher{
|
||||
repo: repo,
|
||||
producer: producer,
|
||||
logger: logger.With("component", "file-outbox-dispatcher", "backend", producer.Backend()),
|
||||
retryPolicy: queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay),
|
||||
pollInterval: cfg.OutboxPollInterval,
|
||||
dispatchTimeout: cfg.OutboxDispatchTimeout,
|
||||
lockTTL: cfg.OutboxLockTTL,
|
||||
maxAttempts: cfg.OutboxMaxAttempts,
|
||||
batchSize: cfg.OutboxBatchSize,
|
||||
workerID: workerID,
|
||||
metrics: &outboxMetrics{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *FileProcessingOutboxDispatcher) Metrics() OutboxMetricsSnapshot {
|
||||
if d == nil {
|
||||
return OutboxMetricsSnapshot{}
|
||||
}
|
||||
return d.metrics.Snapshot()
|
||||
}
|
||||
|
||||
func (d *FileProcessingOutboxDispatcher) Run(ctx context.Context) error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.pollInterval <= 0 {
|
||||
d.pollInterval = time.Second
|
||||
}
|
||||
for {
|
||||
processed, err := d.dispatchOnce(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = err.Error()
|
||||
})
|
||||
d.logger.Error("file outbox dispatch cycle failed", slog.Any("error", err))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if processed > 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(d.pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *FileProcessingOutboxDispatcher) dispatchOnce(ctx context.Context) (int, error) {
|
||||
now := time.Now().UTC()
|
||||
messages, err := d.repo.ClaimPendingFileProcessingOutboxMessages(ctx, d.batchSize, now, d.lockTTL, d.workerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
d.refreshPending(ctx, now)
|
||||
return 0, nil
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Claimed += int64(len(messages))
|
||||
})
|
||||
for i := range messages {
|
||||
d.dispatchMessage(ctx, &messages[i])
|
||||
}
|
||||
d.refreshPending(ctx, time.Now().UTC())
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
func (d *FileProcessingOutboxDispatcher) dispatchMessage(ctx context.Context, message *filemanager.FileProcessingOutboxMessage) {
|
||||
if message == nil {
|
||||
return
|
||||
}
|
||||
outbound, err := message.OutboundMessage()
|
||||
if err != nil {
|
||||
d.failPermanently(ctx, message, err)
|
||||
return
|
||||
}
|
||||
queueMessage := queue.OutboundMessage{
|
||||
ID: outbound.ID,
|
||||
Body: outbound.Body,
|
||||
Attributes: outbound.Attributes,
|
||||
MessageGroupID: outbound.MessageGroupID,
|
||||
DeduplicationID: outbound.DeduplicationID,
|
||||
}
|
||||
|
||||
dispatchCtx := ctx
|
||||
cancel := func() {}
|
||||
if d.dispatchTimeout > 0 {
|
||||
dispatchCtx, cancel = context.WithTimeout(ctx, d.dispatchTimeout)
|
||||
}
|
||||
err = d.producer.Publish(dispatchCtx, queueMessage)
|
||||
cancel()
|
||||
if err == nil {
|
||||
publishedAt := time.Now().UTC()
|
||||
if markErr := d.repo.MarkFileProcessingOutboxMessagePublished(ctx, message.ID, publishedAt); markErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = markErr.Error()
|
||||
})
|
||||
d.logger.Error("file outbox publish marked failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", markErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Published++
|
||||
snapshot.LastSuccessAt = publishedAt
|
||||
snapshot.LastError = ""
|
||||
})
|
||||
d.logger.Info("file outbox message published",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.String("correlation_id", message.CorrelationID),
|
||||
slog.Int("attempt", message.Attempts),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if message.Attempts >= d.maxAttempts {
|
||||
d.failPermanently(ctx, message, err)
|
||||
return
|
||||
}
|
||||
|
||||
decision := d.retryPolicy.Decide(&queue.Delivery{ID: message.MessageID, ReceiveCount: message.Attempts}, nil, err)
|
||||
nextAttemptAt := time.Now().UTC().Add(decision.Delay)
|
||||
if retryErr := d.repo.MarkFileProcessingOutboxMessageRetry(ctx, message.ID, nextAttemptAt, err.Error()); retryErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = retryErr.Error()
|
||||
})
|
||||
d.logger.Error("file outbox retry scheduling failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", retryErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Retried++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = err.Error()
|
||||
})
|
||||
d.logger.Warn("file outbox publish failed; scheduled retry",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.Duration("retry_in", decision.Delay),
|
||||
slog.Int("attempt", message.Attempts),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *FileProcessingOutboxDispatcher) failPermanently(ctx context.Context, message *filemanager.FileProcessingOutboxMessage, err error) {
|
||||
if message == nil || err == nil {
|
||||
return
|
||||
}
|
||||
failedAt := time.Now().UTC()
|
||||
if markErr := d.repo.MarkFileProcessingOutboxMessageDead(ctx, message.ID, failedAt, err.Error()); markErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = failedAt
|
||||
snapshot.LastError = markErr.Error()
|
||||
})
|
||||
d.logger.Error("file outbox dead-letter marking failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", markErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Dead++
|
||||
snapshot.LastFailureAt = failedAt
|
||||
snapshot.LastError = err.Error()
|
||||
})
|
||||
d.logger.Error("file outbox message marked dead",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.Int("attempt", message.Attempts),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *FileProcessingOutboxDispatcher) refreshPending(ctx context.Context, now time.Time) {
|
||||
count, err := d.repo.CountPendingFileProcessingOutboxMessages(ctx, now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.ApproximatePending = count
|
||||
})
|
||||
}
|
||||
219
internal/app/worker/file_processing_outbox_dispatcher_test.go
Normal file
219
internal/app/worker/file_processing_outbox_dispatcher_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type stubFileOutboxRepo struct {
|
||||
messages map[string]*filemanager.FileProcessingOutboxMessage
|
||||
}
|
||||
|
||||
func newStubFileOutboxRepo(messages ...*filemanager.FileProcessingOutboxMessage) *stubFileOutboxRepo {
|
||||
repo := &stubFileOutboxRepo{messages: map[string]*filemanager.FileProcessingOutboxMessage{}}
|
||||
for _, message := range messages {
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
cp := *message
|
||||
cp.ID = append([]byte(nil), message.ID...)
|
||||
cp.Body = append([]byte(nil), message.Body...)
|
||||
cp.Attributes = append([]byte(nil), message.Attributes...)
|
||||
repo.messages[string(cp.ID)] = &cp
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r *stubFileOutboxRepo) CreateFileProcessingOutboxMessage(_ context.Context, message *filemanager.FileProcessingOutboxMessage) error {
|
||||
if message == nil {
|
||||
return errors.New("nil message")
|
||||
}
|
||||
cp := *message
|
||||
cp.ID = append([]byte(nil), message.ID...)
|
||||
cp.Body = append([]byte(nil), message.Body...)
|
||||
cp.Attributes = append([]byte(nil), message.Attributes...)
|
||||
r.messages[string(cp.ID)] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubFileOutboxRepo) ClaimPendingFileProcessingOutboxMessages(_ context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]filemanager.FileProcessingOutboxMessage, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = time.Minute
|
||||
}
|
||||
out := make([]filemanager.FileProcessingOutboxMessage, 0, limit)
|
||||
staleBefore := now.Add(-lockTTL)
|
||||
for _, message := range r.messages {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case message.Status == filemanager.FileProcessingOutboxStatusPending && !message.AvailableAt.After(now):
|
||||
case message.Status == filemanager.FileProcessingOutboxStatusProcessing && message.LockedAt != nil && message.LockedAt.Before(staleBefore):
|
||||
default:
|
||||
continue
|
||||
}
|
||||
message.Status = filemanager.FileProcessingOutboxStatusProcessing
|
||||
message.Attempts++
|
||||
ts := now
|
||||
message.LockedAt = &ts
|
||||
message.LockedBy = workerID
|
||||
cp := *message
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *stubFileOutboxRepo) MarkFileProcessingOutboxMessagePublished(_ context.Context, id []byte, publishedAt time.Time) error {
|
||||
message := r.messages[string(id)]
|
||||
if message == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
message.Status = filemanager.FileProcessingOutboxStatusPublished
|
||||
message.PublishedAt = &publishedAt
|
||||
message.LockedAt = nil
|
||||
message.LockedBy = ""
|
||||
message.LastError = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubFileOutboxRepo) MarkFileProcessingOutboxMessageRetry(_ context.Context, id []byte, availableAt time.Time, lastErr string) error {
|
||||
message := r.messages[string(id)]
|
||||
if message == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
message.Status = filemanager.FileProcessingOutboxStatusPending
|
||||
message.AvailableAt = availableAt
|
||||
message.LockedAt = nil
|
||||
message.LockedBy = ""
|
||||
message.LastError = lastErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubFileOutboxRepo) MarkFileProcessingOutboxMessageDead(_ context.Context, id []byte, failedAt time.Time, lastErr string) error {
|
||||
message := r.messages[string(id)]
|
||||
if message == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
message.Status = filemanager.FileProcessingOutboxStatusDead
|
||||
message.AvailableAt = failedAt
|
||||
message.LockedAt = nil
|
||||
message.LockedBy = ""
|
||||
message.LastError = lastErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubFileOutboxRepo) CountPendingFileProcessingOutboxMessages(_ context.Context, now time.Time) (int64, error) {
|
||||
var count int64
|
||||
for _, message := range r.messages {
|
||||
if (message.Status == filemanager.FileProcessingOutboxStatusPending && !message.AvailableAt.After(now)) || message.Status == filemanager.FileProcessingOutboxStatusProcessing {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func newFileOutboxRecord(t *testing.T, job queue.FileProcessingJob) *filemanager.FileProcessingOutboxMessage {
|
||||
t.Helper()
|
||||
serializer := queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, "")
|
||||
message, err := serializer.Serialize(context.Background(), job)
|
||||
if err != nil {
|
||||
t.Fatalf("serialize: %v", err)
|
||||
}
|
||||
record, err := filemanager.NewFileProcessingOutboxMessage(time.Now().UTC(), &filemanager.FileProcessingOutboundMessage{
|
||||
ID: message.ID,
|
||||
Body: append([]byte(nil), message.Body...),
|
||||
Attributes: message.Attributes,
|
||||
MessageGroupID: message.MessageGroupID,
|
||||
DeduplicationID: message.DeduplicationID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new outbox message: %v", err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func TestFileProcessingOutboxDispatcherPublishSuccess(t *testing.T) {
|
||||
record := newFileOutboxRecord(t, queue.FileProcessingJob{
|
||||
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e498",
|
||||
Bucket: "bucket-a",
|
||||
ObjectKey: "files/2026/04/05/object.pdf",
|
||||
SizeBytes: 128,
|
||||
})
|
||||
repo := newStubFileOutboxRepo(record)
|
||||
producer := &stubQueueProducer{}
|
||||
dispatcher := NewFileProcessingOutboxDispatcher(config.FileQueueConfig{
|
||||
OutboxEnabled: true,
|
||||
OutboxBatchSize: 10,
|
||||
OutboxDispatchTimeout: time.Second,
|
||||
OutboxLockTTL: time.Minute,
|
||||
OutboxMaxAttempts: 3,
|
||||
WorkerBackoffMin: time.Second,
|
||||
WorkerBackoffMax: time.Minute,
|
||||
}, repo, producer, nil)
|
||||
|
||||
processed, err := dispatcher.dispatchOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch once: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed message, got %d", processed)
|
||||
}
|
||||
if len(producer.published) != 1 {
|
||||
t.Fatalf("expected one published message, got %d", len(producer.published))
|
||||
}
|
||||
if repo.messages[string(record.ID)].Status != filemanager.FileProcessingOutboxStatusPublished {
|
||||
t.Fatalf("expected message published, got %s", repo.messages[string(record.ID)].Status)
|
||||
}
|
||||
if got := dispatcher.Metrics().Published; got != 1 {
|
||||
t.Fatalf("expected published metric=1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileProcessingOutboxDispatcherRetryOnPublishFailure(t *testing.T) {
|
||||
record := newFileOutboxRecord(t, queue.FileProcessingJob{
|
||||
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e499",
|
||||
Bucket: "bucket-a",
|
||||
ObjectKey: "files/2026/04/05/object.pdf",
|
||||
SizeBytes: 256,
|
||||
})
|
||||
repo := newStubFileOutboxRepo(record)
|
||||
producer := &stubQueueProducer{err: errors.New("queue unavailable")}
|
||||
dispatcher := NewFileProcessingOutboxDispatcher(config.FileQueueConfig{
|
||||
OutboxEnabled: true,
|
||||
OutboxBatchSize: 10,
|
||||
OutboxLockTTL: time.Minute,
|
||||
OutboxMaxAttempts: 3,
|
||||
WorkerBackoffMin: time.Second,
|
||||
WorkerBackoffMax: time.Minute,
|
||||
}, repo, producer, nil)
|
||||
|
||||
processed, err := dispatcher.dispatchOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch once: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed message, got %d", processed)
|
||||
}
|
||||
got := repo.messages[string(record.ID)]
|
||||
if got.Status != filemanager.FileProcessingOutboxStatusPending {
|
||||
t.Fatalf("expected message returned to pending, got %s", got.Status)
|
||||
}
|
||||
if got.Attempts != 1 {
|
||||
t.Fatalf("expected attempts=1, got %d", got.Attempts)
|
||||
}
|
||||
if !got.AvailableAt.After(time.Now().Add(-time.Second)) {
|
||||
t.Fatalf("expected retry to schedule next attempt in the future")
|
||||
}
|
||||
if metrics := dispatcher.Metrics(); metrics.Retried != 1 {
|
||||
t.Fatalf("expected retried metric=1, got %d", metrics.Retried)
|
||||
}
|
||||
}
|
||||
193
internal/app/worker/file_processing_runner.go
Normal file
193
internal/app/worker/file_processing_runner.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/shared/pkg/metrics"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type FileProcessingRunner struct {
|
||||
worker *queue.FileProcessingWorker
|
||||
consumer queue.QueueConsumer
|
||||
dispatcher *FileProcessingOutboxDispatcher
|
||||
logger *slog.Logger
|
||||
monitorAddr string
|
||||
depthPollInterval time.Duration
|
||||
shutdownTimeout time.Duration
|
||||
backend string
|
||||
monitorServer *http.Server
|
||||
}
|
||||
|
||||
func NewFileProcessingRunner(
|
||||
cfg config.FileQueueConfig,
|
||||
consumer queue.QueueConsumer,
|
||||
serializer queue.FileProcessingMessageSerializer,
|
||||
handler queue.FileProcessingJobHandler,
|
||||
idempotency queue.IdempotencyStore,
|
||||
dispatcher *FileProcessingOutboxDispatcher,
|
||||
logger *slog.Logger,
|
||||
) *FileProcessingRunner {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
metrics := queue.NewMetrics()
|
||||
health := queue.NewHealthReporter(cfg.WorkerHealthErrorThreshold)
|
||||
worker := queue.NewFileProcessingWorker(queue.WorkerConfig{
|
||||
Concurrency: cfg.WorkerConcurrency,
|
||||
MaxBatchSize: cfg.WorkerMaxBatchSize,
|
||||
ProcessTimeout: cfg.WorkerProcessTimeout,
|
||||
ProcessingTTL: cfg.ProcessingTTL,
|
||||
IdempotencyTTL: cfg.IdempotencyTTL,
|
||||
PollErrorDelay: cfg.WorkerBackoffMin,
|
||||
MetricsComponent: "file_worker",
|
||||
}, consumer, serializer, handler, queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay), idempotency, metrics, health, logger)
|
||||
|
||||
backend := ""
|
||||
if consumer != nil {
|
||||
backend = consumer.Backend()
|
||||
}
|
||||
return &FileProcessingRunner{
|
||||
worker: worker,
|
||||
consumer: consumer,
|
||||
dispatcher: dispatcher,
|
||||
logger: logger,
|
||||
monitorAddr: cfg.WorkerMonitorAddr,
|
||||
depthPollInterval: cfg.WorkerDepthPollInterval,
|
||||
shutdownTimeout: cfg.WorkerShutdownTimeout,
|
||||
backend: backend,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FileProcessingRunner) Start(ctx context.Context) error {
|
||||
if r.worker == nil {
|
||||
return errors.New("worker is required")
|
||||
}
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
if err := r.startMonitor(); err != nil {
|
||||
return err
|
||||
}
|
||||
pollerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pollerDone)
|
||||
r.runDepthPoller(runCtx)
|
||||
}()
|
||||
|
||||
workerErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
workerErrCh <- r.worker.Run(runCtx)
|
||||
}()
|
||||
|
||||
var dispatcherErrCh chan error
|
||||
if r.dispatcher != nil {
|
||||
dispatcherErrCh = make(chan error, 1)
|
||||
go func() {
|
||||
dispatcherErrCh <- r.dispatcher.Run(runCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-workerErrCh:
|
||||
cancelRun()
|
||||
if dispatcherErrCh != nil {
|
||||
if dispatcherErr := <-dispatcherErrCh; err == nil {
|
||||
err = dispatcherErr
|
||||
}
|
||||
}
|
||||
case err = <-dispatcherErrCh:
|
||||
cancelRun()
|
||||
if workerErr := <-workerErrCh; err == nil {
|
||||
err = workerErr
|
||||
}
|
||||
}
|
||||
|
||||
cancelRun()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), r.shutdownTimeout)
|
||||
defer cancel()
|
||||
if r.monitorServer != nil {
|
||||
_ = r.monitorServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
if r.consumer != nil {
|
||||
_ = r.consumer.Close()
|
||||
}
|
||||
<-pollerDone
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *FileProcessingRunner) startMonitor() error {
|
||||
if r.monitorAddr == "" {
|
||||
return nil
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", r.handleHealth)
|
||||
mux.Handle("/metrics", metrics.PromHTTPHandler())
|
||||
r.monitorServer = &http.Server{
|
||||
Addr: r.monitorAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
r.logger.Info("file worker monitor listening",
|
||||
slog.String("addr", r.monitorAddr),
|
||||
slog.String("backend", r.backend),
|
||||
)
|
||||
if err := r.monitorServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
r.logger.Error("file worker monitor failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FileProcessingRunner) runDepthPoller(ctx context.Context) {
|
||||
if r.depthPollInterval <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(r.depthPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
_ = r.worker.RefreshQueueDepth(refreshCtx)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FileProcessingRunner) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
snapshot := struct {
|
||||
Backend string `json:"backend"`
|
||||
Health queue.HealthSnapshot `json:"health"`
|
||||
Metrics queue.MetricsSnapshot `json:"metrics"`
|
||||
Outbox *OutboxMetricsSnapshot `json:"outbox,omitempty"`
|
||||
}{
|
||||
Backend: r.backend,
|
||||
Health: r.worker.Health().Snapshot(),
|
||||
Metrics: r.worker.Metrics().Snapshot(),
|
||||
}
|
||||
if r.dispatcher != nil {
|
||||
snapshot.Outbox = ptr(r.dispatcher.Metrics())
|
||||
}
|
||||
statusCode := http.StatusOK
|
||||
if snapshot.Health.Status == "degraded" {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
writeFileWorkerJSON(w, statusCode, snapshot)
|
||||
}
|
||||
|
||||
func writeFileWorkerJSON(w http.ResponseWriter, statusCode int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
342
internal/app/worker/outbox_dispatcher.go
Normal file
342
internal/app/worker/outbox_dispatcher.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type OutboxMetricsSnapshot struct {
|
||||
Claimed int64 `json:"claimed"`
|
||||
Published int64 `json:"published"`
|
||||
Retried int64 `json:"retried"`
|
||||
Dead int64 `json:"dead"`
|
||||
Failures int64 `json:"failures"`
|
||||
ApproximatePending int64 `json:"approximate_pending"`
|
||||
LastSuccessAt time.Time `json:"last_success_at"`
|
||||
LastFailureAt time.Time `json:"last_failure_at"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type outboxMetrics struct {
|
||||
mu sync.Mutex
|
||||
snapshot OutboxMetricsSnapshot
|
||||
}
|
||||
|
||||
func (m *outboxMetrics) Snapshot() OutboxMetricsSnapshot {
|
||||
if m == nil {
|
||||
return OutboxMetricsSnapshot{}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.snapshot
|
||||
}
|
||||
|
||||
func (m *outboxMetrics) update(fn func(snapshot *OutboxMetricsSnapshot)) {
|
||||
if m == nil || fn == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
fn(&m.snapshot)
|
||||
}
|
||||
|
||||
type OutboxDispatcher struct {
|
||||
repo auth.EmailOutboxRepository
|
||||
producer queue.QueueProducer
|
||||
logger *slog.Logger
|
||||
retryPolicy queue.RetryPolicy
|
||||
pollInterval time.Duration
|
||||
dispatchTimeout time.Duration
|
||||
lockTTL time.Duration
|
||||
maxAttempts int
|
||||
batchSize int
|
||||
workerID string
|
||||
metrics *outboxMetrics
|
||||
}
|
||||
|
||||
func NewOutboxDispatcher(
|
||||
cfg config.QueueConfig,
|
||||
repo auth.EmailOutboxRepository,
|
||||
producer queue.QueueProducer,
|
||||
logger *slog.Logger,
|
||||
) *OutboxDispatcher {
|
||||
if !cfg.OutboxEnabled || repo == nil || producer == nil {
|
||||
return nil
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
workerID := fmt.Sprintf("%s-%d", hostname(), os.Getpid())
|
||||
return &OutboxDispatcher{
|
||||
repo: repo,
|
||||
producer: producer,
|
||||
logger: logger.With("component", "outbox-dispatcher", "backend", producer.Backend()),
|
||||
retryPolicy: queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay),
|
||||
pollInterval: cfg.OutboxPollInterval,
|
||||
dispatchTimeout: cfg.OutboxDispatchTimeout,
|
||||
lockTTL: cfg.OutboxLockTTL,
|
||||
maxAttempts: cfg.OutboxMaxAttempts,
|
||||
batchSize: cfg.OutboxBatchSize,
|
||||
workerID: workerID,
|
||||
metrics: &outboxMetrics{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OutboxDispatcher) Metrics() OutboxMetricsSnapshot {
|
||||
if d == nil {
|
||||
return OutboxMetricsSnapshot{}
|
||||
}
|
||||
return d.metrics.Snapshot()
|
||||
}
|
||||
|
||||
func (d *OutboxDispatcher) Run(ctx context.Context) error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.pollInterval <= 0 {
|
||||
d.pollInterval = time.Second
|
||||
}
|
||||
for {
|
||||
processed, err := d.dispatchOnce(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = err.Error()
|
||||
})
|
||||
d.logger.Error("outbox dispatch cycle failed", slog.Any("error", err))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if processed > 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(d.pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OutboxDispatcher) dispatchOnce(ctx context.Context) (int, error) {
|
||||
now := time.Now().UTC()
|
||||
messages, err := d.repo.ClaimPendingEmailOutboxMessages(ctx, d.batchSize, now, d.lockTTL, d.workerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
d.refreshPending(ctx, now)
|
||||
return 0, nil
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Claimed += int64(len(messages))
|
||||
})
|
||||
for i := range messages {
|
||||
d.dispatchMessage(ctx, &messages[i])
|
||||
}
|
||||
d.refreshPending(ctx, time.Now().UTC())
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
func (d *OutboxDispatcher) dispatchMessage(ctx context.Context, message *auth.EmailOutboxMessage) {
|
||||
if message == nil {
|
||||
return
|
||||
}
|
||||
outbound, err := message.OutboundMessage()
|
||||
if err != nil {
|
||||
d.failPermanently(ctx, message, err)
|
||||
return
|
||||
}
|
||||
if shouldSkipOutboxEmailPublish(outbound) {
|
||||
publishedAt := time.Now().UTC()
|
||||
if markErr := d.repo.MarkEmailOutboxMessagePublished(ctx, message.ID, publishedAt); markErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = markErr.Error()
|
||||
})
|
||||
d.logger.Error("outbox skip marked failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", markErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Published++
|
||||
snapshot.LastSuccessAt = publishedAt
|
||||
snapshot.LastError = ""
|
||||
})
|
||||
d.logger.Info("outbox message skipped",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.String("correlation_id", message.CorrelationID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
dispatchCtx := ctx
|
||||
cancel := func() {}
|
||||
if d.dispatchTimeout > 0 {
|
||||
dispatchCtx, cancel = context.WithTimeout(ctx, d.dispatchTimeout)
|
||||
}
|
||||
err = d.producer.Publish(dispatchCtx, *outbound)
|
||||
cancel()
|
||||
if err == nil {
|
||||
publishedAt := time.Now().UTC()
|
||||
if markErr := d.repo.MarkEmailOutboxMessagePublished(ctx, message.ID, publishedAt); markErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = markErr.Error()
|
||||
})
|
||||
d.logger.Error("outbox publish marked failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", markErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Published++
|
||||
snapshot.LastSuccessAt = publishedAt
|
||||
snapshot.LastError = ""
|
||||
})
|
||||
d.logger.Info("outbox message published",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.String("correlation_id", message.CorrelationID),
|
||||
slog.Int("attempt", message.Attempts),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if message.Attempts >= d.maxAttempts {
|
||||
d.failPermanently(ctx, message, err)
|
||||
return
|
||||
}
|
||||
|
||||
decision := d.retryPolicy.Decide(&queue.Delivery{ID: message.MessageID, ReceiveCount: message.Attempts}, nil, err)
|
||||
nextAttemptAt := time.Now().UTC().Add(decision.Delay)
|
||||
if retryErr := d.repo.MarkEmailOutboxMessageRetry(ctx, message.ID, nextAttemptAt, err.Error()); retryErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = retryErr.Error()
|
||||
})
|
||||
d.logger.Error("outbox retry scheduling failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", retryErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Retried++
|
||||
snapshot.LastFailureAt = time.Now().UTC()
|
||||
snapshot.LastError = err.Error()
|
||||
})
|
||||
d.logger.Warn("outbox publish failed; scheduled retry",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.Duration("retry_in", decision.Delay),
|
||||
slog.Int("attempt", message.Attempts),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
func shouldSkipOutboxEmailPublish(outbound *queue.OutboundMessage) bool {
|
||||
if outbound == nil || len(outbound.Body) == 0 {
|
||||
return false
|
||||
}
|
||||
var envelope struct {
|
||||
Payload struct {
|
||||
MessageType string `json:"message_type"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(outbound.Body, &envelope); err == nil {
|
||||
if strings.EqualFold(strings.TrimSpace(envelope.Payload.Metadata["skip_send"]), "true") {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(envelope.Payload.MessageType), "email_verify") &&
|
||||
strings.EqualFold(strings.TrimSpace(envelope.Payload.Tags["source"]), "register") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
var legacyJob struct {
|
||||
MessageType string `json:"message_type"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal(outbound.Body, &legacyJob); err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(legacyJob.Metadata["skip_send"]), "true") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(legacyJob.MessageType), "email_verify") &&
|
||||
strings.EqualFold(strings.TrimSpace(legacyJob.Tags["source"]), "register")
|
||||
}
|
||||
|
||||
func (d *OutboxDispatcher) failPermanently(ctx context.Context, message *auth.EmailOutboxMessage, err error) {
|
||||
if message == nil || err == nil {
|
||||
return
|
||||
}
|
||||
failedAt := time.Now().UTC()
|
||||
if markErr := d.repo.MarkEmailOutboxMessageDead(ctx, message.ID, failedAt, err.Error()); markErr != nil {
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Failures++
|
||||
snapshot.LastFailureAt = failedAt
|
||||
snapshot.LastError = markErr.Error()
|
||||
})
|
||||
d.logger.Error("outbox dead-letter marking failed",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.Any("error", markErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.Dead++
|
||||
snapshot.LastFailureAt = failedAt
|
||||
snapshot.LastError = err.Error()
|
||||
})
|
||||
d.logger.Error("outbox message marked dead",
|
||||
slog.String("message_id", message.MessageID),
|
||||
slog.String("job_type", message.JobType),
|
||||
slog.Int("attempt", message.Attempts),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *OutboxDispatcher) refreshPending(ctx context.Context, now time.Time) {
|
||||
count, err := d.repo.CountPendingEmailOutboxMessages(ctx, now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
|
||||
snapshot.ApproximatePending = count
|
||||
})
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
name, err := os.Hostname()
|
||||
if err != nil || strings.TrimSpace(name) == "" {
|
||||
return "worker"
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
304
internal/app/worker/outbox_dispatcher_test.go
Normal file
304
internal/app/worker/outbox_dispatcher_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type stubOutboxRepo struct {
|
||||
messages map[string]*auth.EmailOutboxMessage
|
||||
}
|
||||
|
||||
func newStubOutboxRepo(messages ...*auth.EmailOutboxMessage) *stubOutboxRepo {
|
||||
repo := &stubOutboxRepo{messages: map[string]*auth.EmailOutboxMessage{}}
|
||||
for _, message := range messages {
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
cp := *message
|
||||
cp.ID = append([]byte(nil), message.ID...)
|
||||
cp.Body = append([]byte(nil), message.Body...)
|
||||
cp.Attributes = append([]byte(nil), message.Attributes...)
|
||||
repo.messages[string(cp.ID)] = &cp
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r *stubOutboxRepo) CreateEmailOutboxMessage(_ context.Context, message *auth.EmailOutboxMessage) error {
|
||||
if message == nil {
|
||||
return errors.New("nil message")
|
||||
}
|
||||
cp := *message
|
||||
cp.ID = append([]byte(nil), message.ID...)
|
||||
cp.Body = append([]byte(nil), message.Body...)
|
||||
cp.Attributes = append([]byte(nil), message.Attributes...)
|
||||
r.messages[string(cp.ID)] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubOutboxRepo) ClaimPendingEmailOutboxMessages(_ context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]auth.EmailOutboxMessage, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = time.Minute
|
||||
}
|
||||
out := make([]auth.EmailOutboxMessage, 0, limit)
|
||||
staleBefore := now.Add(-lockTTL)
|
||||
for _, message := range r.messages {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case message.Status == auth.EmailOutboxStatusPending && !message.AvailableAt.After(now):
|
||||
case message.Status == auth.EmailOutboxStatusProcessing && message.LockedAt != nil && message.LockedAt.Before(staleBefore):
|
||||
default:
|
||||
continue
|
||||
}
|
||||
message.Status = auth.EmailOutboxStatusProcessing
|
||||
message.Attempts++
|
||||
ts := now
|
||||
message.LockedAt = &ts
|
||||
message.LockedBy = workerID
|
||||
cp := *message
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *stubOutboxRepo) MarkEmailOutboxMessagePublished(_ context.Context, id []byte, publishedAt time.Time) error {
|
||||
message := r.messages[string(id)]
|
||||
if message == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
message.Status = auth.EmailOutboxStatusPublished
|
||||
message.PublishedAt = &publishedAt
|
||||
message.LockedAt = nil
|
||||
message.LockedBy = ""
|
||||
message.LastError = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubOutboxRepo) MarkEmailOutboxMessageRetry(_ context.Context, id []byte, availableAt time.Time, lastErr string) error {
|
||||
message := r.messages[string(id)]
|
||||
if message == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
message.Status = auth.EmailOutboxStatusPending
|
||||
message.AvailableAt = availableAt
|
||||
message.LockedAt = nil
|
||||
message.LockedBy = ""
|
||||
message.LastError = lastErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubOutboxRepo) MarkEmailOutboxMessageDead(_ context.Context, id []byte, failedAt time.Time, lastErr string) error {
|
||||
message := r.messages[string(id)]
|
||||
if message == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
message.Status = auth.EmailOutboxStatusDead
|
||||
message.AvailableAt = failedAt
|
||||
message.LockedAt = nil
|
||||
message.LockedBy = ""
|
||||
message.LastError = lastErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubOutboxRepo) CountPendingEmailOutboxMessages(_ context.Context, now time.Time) (int64, error) {
|
||||
var count int64
|
||||
for _, message := range r.messages {
|
||||
if (message.Status == auth.EmailOutboxStatusPending && !message.AvailableAt.After(now)) || message.Status == auth.EmailOutboxStatusProcessing {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
type stubQueueProducer struct {
|
||||
published []queue.OutboundMessage
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *stubQueueProducer) Backend() string {
|
||||
return "stub"
|
||||
}
|
||||
|
||||
func (p *stubQueueProducer) Publish(_ context.Context, message queue.OutboundMessage) error {
|
||||
if p.err != nil {
|
||||
return p.err
|
||||
}
|
||||
p.published = append(p.published, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newOutboxRecord(t *testing.T, job queue.EmailJob) *auth.EmailOutboxMessage {
|
||||
t.Helper()
|
||||
serializer := queue.NewJSONSerializer("v1", true, queue.QueueTypeStandard, "")
|
||||
message, err := serializer.Serialize(context.Background(), job)
|
||||
if err != nil {
|
||||
t.Fatalf("serialize: %v", err)
|
||||
}
|
||||
record, err := auth.NewEmailOutboxMessage(time.Now().UTC(), message)
|
||||
if err != nil {
|
||||
t.Fatalf("new outbox message: %v", err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func TestOutboxDispatcherPublishSuccess(t *testing.T) {
|
||||
record := newOutboxRecord(t, queue.EmailJob{
|
||||
Type: "email_verify",
|
||||
To: "user@example.com",
|
||||
Subject: "Verify",
|
||||
Body: "hello",
|
||||
})
|
||||
repo := newStubOutboxRepo(record)
|
||||
producer := &stubQueueProducer{}
|
||||
dispatcher := NewOutboxDispatcher(config.QueueConfig{
|
||||
OutboxEnabled: true,
|
||||
OutboxBatchSize: 10,
|
||||
OutboxDispatchTimeout: time.Second,
|
||||
OutboxLockTTL: time.Minute,
|
||||
OutboxMaxAttempts: 3,
|
||||
WorkerBackoffMin: time.Second,
|
||||
WorkerBackoffMax: time.Minute,
|
||||
}, repo, producer, nil)
|
||||
|
||||
processed, err := dispatcher.dispatchOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch once: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed message, got %d", processed)
|
||||
}
|
||||
if len(producer.published) != 1 {
|
||||
t.Fatalf("expected one published message, got %d", len(producer.published))
|
||||
}
|
||||
if repo.messages[string(record.ID)].Status != auth.EmailOutboxStatusPublished {
|
||||
t.Fatalf("expected message published, got %s", repo.messages[string(record.ID)].Status)
|
||||
}
|
||||
if got := dispatcher.Metrics().Published; got != 1 {
|
||||
t.Fatalf("expected published metric=1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxDispatcherSkipRegisterVerifyEmail(t *testing.T) {
|
||||
record := newOutboxRecord(t, queue.EmailJob{
|
||||
MessageType: "email_verify",
|
||||
To: "user@example.com",
|
||||
Subject: "Verify",
|
||||
TextBody: "hello",
|
||||
Metadata: map[string]string{
|
||||
"skip_send": "true",
|
||||
},
|
||||
Tags: map[string]string{
|
||||
"source": "register",
|
||||
},
|
||||
})
|
||||
repo := newStubOutboxRepo(record)
|
||||
producer := &stubQueueProducer{}
|
||||
dispatcher := NewOutboxDispatcher(config.QueueConfig{
|
||||
OutboxEnabled: true,
|
||||
OutboxBatchSize: 10,
|
||||
OutboxDispatchTimeout: time.Second,
|
||||
OutboxLockTTL: time.Minute,
|
||||
OutboxMaxAttempts: 3,
|
||||
WorkerBackoffMin: time.Second,
|
||||
WorkerBackoffMax: time.Minute,
|
||||
}, repo, producer, nil)
|
||||
|
||||
processed, err := dispatcher.dispatchOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch once: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed message, got %d", processed)
|
||||
}
|
||||
if len(producer.published) != 0 {
|
||||
t.Fatalf("expected zero published message, got %d", len(producer.published))
|
||||
}
|
||||
if repo.messages[string(record.ID)].Status != auth.EmailOutboxStatusPublished {
|
||||
t.Fatalf("expected message published status after skip, got %s", repo.messages[string(record.ID)].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxDispatcherRetryOnPublishFailure(t *testing.T) {
|
||||
record := newOutboxRecord(t, queue.EmailJob{
|
||||
Type: "password_reset",
|
||||
To: "user@example.com",
|
||||
Subject: "Reset",
|
||||
Body: "reset",
|
||||
})
|
||||
repo := newStubOutboxRepo(record)
|
||||
producer := &stubQueueProducer{err: errors.New("queue unavailable")}
|
||||
dispatcher := NewOutboxDispatcher(config.QueueConfig{
|
||||
OutboxEnabled: true,
|
||||
OutboxBatchSize: 10,
|
||||
OutboxLockTTL: time.Minute,
|
||||
OutboxMaxAttempts: 3,
|
||||
WorkerBackoffMin: time.Second,
|
||||
WorkerBackoffMax: time.Minute,
|
||||
}, repo, producer, nil)
|
||||
|
||||
processed, err := dispatcher.dispatchOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch once: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed message, got %d", processed)
|
||||
}
|
||||
got := repo.messages[string(record.ID)]
|
||||
if got.Status != auth.EmailOutboxStatusPending {
|
||||
t.Fatalf("expected message returned to pending, got %s", got.Status)
|
||||
}
|
||||
if got.Attempts != 1 {
|
||||
t.Fatalf("expected attempts=1, got %d", got.Attempts)
|
||||
}
|
||||
if !got.AvailableAt.After(time.Now().Add(-time.Second)) {
|
||||
t.Fatalf("expected retry to schedule next attempt in the future")
|
||||
}
|
||||
if metrics := dispatcher.Metrics(); metrics.Retried != 1 {
|
||||
t.Fatalf("expected retried metric=1, got %d", metrics.Retried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxDispatcherMarksDeadAfterMaxAttempts(t *testing.T) {
|
||||
record := newOutboxRecord(t, queue.EmailJob{
|
||||
Type: "invite_set_password",
|
||||
To: "user@example.com",
|
||||
Subject: "Invite",
|
||||
Body: "invite",
|
||||
})
|
||||
record.Attempts = 2
|
||||
repo := newStubOutboxRepo(record)
|
||||
producer := &stubQueueProducer{err: errors.New("queue unavailable")}
|
||||
dispatcher := NewOutboxDispatcher(config.QueueConfig{
|
||||
OutboxEnabled: true,
|
||||
OutboxBatchSize: 10,
|
||||
OutboxLockTTL: time.Minute,
|
||||
OutboxMaxAttempts: 3,
|
||||
WorkerBackoffMin: time.Second,
|
||||
WorkerBackoffMax: time.Minute,
|
||||
}, repo, producer, nil)
|
||||
|
||||
processed, err := dispatcher.dispatchOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch once: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed message, got %d", processed)
|
||||
}
|
||||
if got := repo.messages[string(record.ID)].Status; got != auth.EmailOutboxStatusDead {
|
||||
t.Fatalf("expected dead status, got %s", got)
|
||||
}
|
||||
if got := dispatcher.Metrics().Dead; got != 1 {
|
||||
t.Fatalf("expected dead metric=1, got %d", got)
|
||||
}
|
||||
}
|
||||
196
internal/app/worker/runner.go
Normal file
196
internal/app/worker/runner.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/shared/pkg/metrics"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
worker *queue.Worker
|
||||
consumer queue.QueueConsumer
|
||||
dispatcher *OutboxDispatcher
|
||||
logger *slog.Logger
|
||||
monitorAddr string
|
||||
depthPollInterval time.Duration
|
||||
shutdownTimeout time.Duration
|
||||
backend string
|
||||
monitorServer *http.Server
|
||||
}
|
||||
|
||||
func NewRunner(
|
||||
cfg config.QueueConfig,
|
||||
consumer queue.QueueConsumer,
|
||||
serializer queue.MessageSerializer,
|
||||
handler queue.EmailJobHandler,
|
||||
idempotency queue.IdempotencyStore,
|
||||
dispatcher *OutboxDispatcher,
|
||||
logger *slog.Logger,
|
||||
) *Runner {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
metrics := queue.NewMetrics()
|
||||
health := queue.NewHealthReporter(cfg.WorkerHealthErrorThreshold)
|
||||
worker := queue.NewWorker(queue.WorkerConfig{
|
||||
Concurrency: cfg.WorkerConcurrency,
|
||||
MaxBatchSize: cfg.WorkerMaxBatchSize,
|
||||
ProcessTimeout: cfg.WorkerProcessTimeout,
|
||||
ProcessingTTL: cfg.ProcessingTTL,
|
||||
IdempotencyTTL: cfg.IdempotencyTTL,
|
||||
PollErrorDelay: cfg.WorkerBackoffMin,
|
||||
MetricsComponent: "email_worker",
|
||||
}, consumer, serializer, handler, queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay), idempotency, metrics, health, logger)
|
||||
|
||||
backend := ""
|
||||
if consumer != nil {
|
||||
backend = consumer.Backend()
|
||||
}
|
||||
return &Runner{
|
||||
worker: worker,
|
||||
consumer: consumer,
|
||||
dispatcher: dispatcher,
|
||||
logger: logger,
|
||||
monitorAddr: cfg.WorkerMonitorAddr,
|
||||
depthPollInterval: cfg.WorkerDepthPollInterval,
|
||||
shutdownTimeout: cfg.WorkerShutdownTimeout,
|
||||
backend: backend,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Start(ctx context.Context) error {
|
||||
if r.worker == nil {
|
||||
return errors.New("worker is required")
|
||||
}
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
if err := r.startMonitor(); err != nil {
|
||||
return err
|
||||
}
|
||||
pollerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pollerDone)
|
||||
r.runDepthPoller(runCtx)
|
||||
}()
|
||||
|
||||
workerErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
workerErrCh <- r.worker.Run(runCtx)
|
||||
}()
|
||||
|
||||
var dispatcherErrCh chan error
|
||||
if r.dispatcher != nil {
|
||||
dispatcherErrCh = make(chan error, 1)
|
||||
go func() {
|
||||
dispatcherErrCh <- r.dispatcher.Run(runCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-workerErrCh:
|
||||
cancelRun()
|
||||
if dispatcherErrCh != nil {
|
||||
if dispatcherErr := <-dispatcherErrCh; err == nil {
|
||||
err = dispatcherErr
|
||||
}
|
||||
}
|
||||
case err = <-dispatcherErrCh:
|
||||
cancelRun()
|
||||
if workerErr := <-workerErrCh; err == nil {
|
||||
err = workerErr
|
||||
}
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), r.shutdownTimeout)
|
||||
defer cancel()
|
||||
if r.monitorServer != nil {
|
||||
_ = r.monitorServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
if r.consumer != nil {
|
||||
_ = r.consumer.Close()
|
||||
}
|
||||
<-pollerDone
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Runner) startMonitor() error {
|
||||
if r.monitorAddr == "" {
|
||||
return nil
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", r.handleHealth)
|
||||
mux.Handle("/metrics", metrics.PromHTTPHandler())
|
||||
r.monitorServer = &http.Server{
|
||||
Addr: r.monitorAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
r.logger.Info("worker monitor listening",
|
||||
slog.String("addr", r.monitorAddr),
|
||||
slog.String("backend", r.backend),
|
||||
)
|
||||
if err := r.monitorServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
r.logger.Error("worker monitor failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) runDepthPoller(ctx context.Context) {
|
||||
if r.depthPollInterval <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(r.depthPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
_ = r.worker.RefreshQueueDepth(refreshCtx)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
snapshot := struct {
|
||||
Backend string `json:"backend"`
|
||||
Health queue.HealthSnapshot `json:"health"`
|
||||
Metrics queue.MetricsSnapshot `json:"metrics"`
|
||||
Outbox *OutboxMetricsSnapshot `json:"outbox,omitempty"`
|
||||
}{
|
||||
Backend: r.backend,
|
||||
Health: r.worker.Health().Snapshot(),
|
||||
Metrics: r.worker.Metrics().Snapshot(),
|
||||
}
|
||||
if r.dispatcher != nil {
|
||||
snapshot.Outbox = ptr(r.dispatcher.Metrics())
|
||||
}
|
||||
statusCode := http.StatusOK
|
||||
if snapshot.Health.Status == "degraded" {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, statusCode, snapshot)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, statusCode int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
699
internal/auth/handler.go
Normal file
699
internal/auth/handler.go
Normal file
@@ -0,0 +1,699 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/service"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
cfg config.Config
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewHandler(cfg config.Config, authSvc *service.AuthService) *Handler {
|
||||
return &Handler{cfg: cfg, auth: authSvc}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSAMLCallback(c *fiber.Ctx) error {
|
||||
if SAMLMiddleware == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
||||
}
|
||||
req, err := toHTTPRequest(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid form payload"})
|
||||
}
|
||||
assertion, err := SAMLMiddleware.ServiceProvider.ParseResponse(req, []string{""})
|
||||
if err != nil {
|
||||
resp := fiber.Map{"error": "invalid saml response"}
|
||||
if strings.EqualFold(h.cfg.AppEnv, "development") || strings.EqualFold(h.cfg.AppEnv, "local") {
|
||||
resp["detail"] = err.Error()
|
||||
var invalidRespErr *saml.InvalidResponseError
|
||||
if errors.As(err, &invalidRespErr) && invalidRespErr.PrivateErr != nil {
|
||||
resp["private_detail"] = invalidRespErr.PrivateErr.Error()
|
||||
}
|
||||
if raw := c.FormValue("SAMLResponse"); raw != "" {
|
||||
if statusCode, statusMessage := samlResponseStatus(raw); statusCode != "" || statusMessage != "" {
|
||||
resp["status_code"] = statusCode
|
||||
resp["status_message"] = statusMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(resp)
|
||||
}
|
||||
|
||||
nameID := ""
|
||||
email := ""
|
||||
givenName := ""
|
||||
surname := ""
|
||||
displayName := ""
|
||||
if assertion.Subject != nil {
|
||||
nameID = assertion.Subject.NameID.Value
|
||||
}
|
||||
for _, stmt := range assertion.AttributeStatements {
|
||||
for _, attr := range stmt.Attributes {
|
||||
if len(attr.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
val := attr.Values[0].Value
|
||||
switch attr.Name {
|
||||
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "emailaddress":
|
||||
email = val
|
||||
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "givenname":
|
||||
givenName = val
|
||||
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "surname":
|
||||
surname = val
|
||||
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name":
|
||||
displayName = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = givenName + " " + surname
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
session := UserSession{
|
||||
UserID: nameID,
|
||||
MicrosoftNameID: nameID,
|
||||
Email: email,
|
||||
Name: displayName,
|
||||
LoggedInAt: now,
|
||||
ExpiresAt: now.Add(time.Duration(h.cfg.SessionTTLHour) * time.Hour),
|
||||
}
|
||||
token, err := CreateSession(session)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create session failed"})
|
||||
}
|
||||
|
||||
if h.auth != nil {
|
||||
claims := map[string]any{
|
||||
"sub": nameID,
|
||||
"email": email,
|
||||
"name": displayName,
|
||||
"oid": nameID,
|
||||
"exp": time.Now().UTC().Add(5 * time.Minute).Unix(),
|
||||
}
|
||||
identity, err := h.auth.ExchangeMicrosoftToken(c.UserContext(), "saml_access", "saml_id", claims)
|
||||
if err == nil && identity != nil {
|
||||
user, userErr := h.auth.GetUserByID(c.UserContext(), identity.UserID)
|
||||
if userErr == nil && user != nil {
|
||||
tokens, tokenErr := h.auth.IssueTokensWithClientForProviderAndDeviceType(
|
||||
c.UserContext(),
|
||||
user,
|
||||
c.Get(fiber.HeaderUserAgent),
|
||||
c.IP(),
|
||||
"microsoft",
|
||||
readDeviceTypeHint(c),
|
||||
)
|
||||
if tokenErr == nil {
|
||||
setAuthCookies(c, h.auth, tokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "session_token",
|
||||
Value: token,
|
||||
HTTPOnly: true,
|
||||
Secure: h.cfg.CookieSecure,
|
||||
SameSite: "lax",
|
||||
Path: "/",
|
||||
Domain: h.cfg.CookieDomain,
|
||||
Expires: session.ExpiresAt,
|
||||
})
|
||||
return c.Redirect(h.cfg.FrontendURL+"/dashboard", fiber.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSLO(c *fiber.Ctx) error {
|
||||
return h.HandleSAMLLogoutRequest(c)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSAMLLogoutRequest(c *fiber.Ctx) error {
|
||||
if SAMLMiddleware == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
||||
}
|
||||
log.Printf("saml logout request: has_saml_request=%t has_saml_response=%t method=%s", formOrQuery(c, "SAMLRequest") != "", formOrQuery(c, "SAMLResponse") != "", c.Method())
|
||||
rawReq := formOrQuery(c, "SAMLRequest")
|
||||
if rawReq == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLRequest"})
|
||||
}
|
||||
|
||||
xmlBytes, err := decodeSAMLMessage(rawReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLRequest encoding"})
|
||||
}
|
||||
if c.Method() == fiber.MethodGet {
|
||||
if err := validateRedirectSAMLSignature(c); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"})
|
||||
}
|
||||
} else {
|
||||
if err := validateLogoutRequestSignature(xmlBytes); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"})
|
||||
}
|
||||
}
|
||||
|
||||
var logoutReq saml.LogoutRequest
|
||||
if err := xml.Unmarshal(xmlBytes, &logoutReq); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutRequest XML"})
|
||||
}
|
||||
if err := validateSAMLMessageIssuer(logoutReq.Issuer.Value); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest issuer"})
|
||||
}
|
||||
nameID := strings.TrimSpace(logoutReq.NameID.Value)
|
||||
h.cleanupUserAuthSession(c, nameID)
|
||||
log.Printf("saml logout request processed: name_id_present=%t", nameID != "")
|
||||
|
||||
relayState := formOrQuery(c, "RelayState")
|
||||
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutResponse(logoutReq.ID, relayState)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build LogoutResponse"})
|
||||
}
|
||||
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSAMLLogoutResponse(c *fiber.Ctx) error {
|
||||
if SAMLMiddleware == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
||||
}
|
||||
rawQuery := c.Context().QueryArgs().String()
|
||||
hasSAMLResponse := formOrQuery(c, "SAMLResponse") != ""
|
||||
hasRelayState := formOrQuery(c, "RelayState") != ""
|
||||
hasSigAlg := formOrQuery(c, "SigAlg") != ""
|
||||
hasSignature := formOrQuery(c, "Signature") != ""
|
||||
sigAlg := formOrQuery(c, "SigAlg")
|
||||
log.Printf("saml logout response: method=%s path=%s hasSAMLResponse=%t hasRelayState=%t hasSigAlg=%t hasSignature=%t sigAlg=%q rawQueryLen=%d",
|
||||
c.Method(), c.Path(), hasSAMLResponse, hasRelayState, hasSigAlg, hasSignature, sigAlg, len(rawQuery))
|
||||
rawResp := formOrQuery(c, "SAMLResponse")
|
||||
if rawResp == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLResponse"})
|
||||
}
|
||||
|
||||
if c.Method() == fiber.MethodGet && hasSigAlg && hasSignature {
|
||||
if err := validateRedirectSAMLSignatureRaw(rawQuery, "SAMLResponse"); err != nil {
|
||||
log.Printf("saml logout response: verification_result=failed mode=redirect reason=%v", err)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
|
||||
}
|
||||
log.Printf("saml logout response: verification_result=ok mode=redirect")
|
||||
}
|
||||
|
||||
xmlBytes, err := decodeSAMLMessage(rawResp)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLResponse encoding"})
|
||||
}
|
||||
|
||||
if c.Method() == fiber.MethodGet && !(hasSigAlg && hasSignature) {
|
||||
if hasSigAlg != hasSignature {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"})
|
||||
}
|
||||
if !hasXMLSignature(xmlBytes) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"})
|
||||
}
|
||||
if err := validateLogoutResponseSignature(xmlBytes); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
|
||||
}
|
||||
} else if c.Method() != fiber.MethodGet {
|
||||
if err := validateLogoutResponseSignature(xmlBytes); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
|
||||
}
|
||||
}
|
||||
|
||||
var logoutResp saml.LogoutResponse
|
||||
if err := xml.Unmarshal(xmlBytes, &logoutResp); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutResponse XML"})
|
||||
}
|
||||
if err := validateSAMLMessageIssuer(logoutResp.Issuer.Value); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse issuer"})
|
||||
}
|
||||
if strings.TrimSpace(logoutResp.Status.StatusCode.Value) != saml.StatusSuccess {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse status"})
|
||||
}
|
||||
h.cleanupUserAuthSession(c, "")
|
||||
return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleMetadata(c *fiber.Ctx) error {
|
||||
if SAMLMiddleware == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).SendString("saml is not initialized")
|
||||
}
|
||||
buf, err := xml.MarshalIndent(SAMLMiddleware.ServiceProvider.Metadata(), "", " ")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).SendString("failed to marshal metadata")
|
||||
}
|
||||
c.Set(fiber.HeaderContentType, "application/samlmetadata+xml")
|
||||
return c.Send(buf)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLogin(c *fiber.Ctx) error {
|
||||
if SAMLMiddleware == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
|
||||
}
|
||||
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectAuthenticationRequest("")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build authn request"})
|
||||
}
|
||||
if wantsJSON(c) {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"data": fiber.Map{
|
||||
"type": "auth_microsoft_url",
|
||||
"attributes": fiber.Map{
|
||||
"url": redirectURL.String(),
|
||||
"redirect_url": redirectURL.String(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLogout(c *fiber.Ctx) error {
|
||||
return h.HandleBrowserLocalLogout(c)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleBrowserLocalLogout(c *fiber.Ctx) error {
|
||||
log.Printf("browser local logout: method=%s", c.Method())
|
||||
var nameID string
|
||||
if token := strings.TrimSpace(c.Cookies("session_token")); token != "" {
|
||||
if sess, err := GetSession(token); err == nil {
|
||||
nameID = strings.TrimSpace(sess.MicrosoftNameID)
|
||||
}
|
||||
}
|
||||
h.cleanupUserAuthSession(c, nameID)
|
||||
if nameID != "" && SAMLMiddleware != nil {
|
||||
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutRequest(nameID, "")
|
||||
if err == nil {
|
||||
return c.Redirect(redirectURL.String(), fiber.StatusFound)
|
||||
}
|
||||
}
|
||||
return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound)
|
||||
}
|
||||
|
||||
func toHTTPRequest(c *fiber.Ctx) (*http.Request, error) {
|
||||
r := new(http.Request)
|
||||
if err := fasthttpadaptor.ConvertRequest(c.Context(), r, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func validateLogoutRequestSignature(xmlBytes []byte) error {
|
||||
certs, err := getIDPCertificates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certStore := dsig.MemoryX509CertificateStore{Roots: certs}
|
||||
validator := dsig.NewDefaultValidationContext(&certStore)
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(xmlBytes); err != nil {
|
||||
return fmt.Errorf("parse xml: %w", err)
|
||||
}
|
||||
root := doc.Root()
|
||||
if root == nil {
|
||||
return fmt.Errorf("empty xml")
|
||||
}
|
||||
_, err = validator.Validate(root)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateRedirectSAMLSignature(c *fiber.Ctx) error {
|
||||
rawQuery := c.Context().QueryArgs().String()
|
||||
return validateRedirectSAMLSignatureRaw(rawQuery, "")
|
||||
}
|
||||
|
||||
func validateRedirectSAMLSignatureRaw(rawQuery, expectedMessageKey string) error {
|
||||
params, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid query: %w", err)
|
||||
}
|
||||
|
||||
sigAlg := params.Get("SigAlg")
|
||||
signatureB64 := params.Get("Signature")
|
||||
if sigAlg == "" || signatureB64 == "" {
|
||||
return fmt.Errorf("missing SigAlg or Signature")
|
||||
}
|
||||
|
||||
signedPayload, err := buildRedirectSignaturePayload(rawQuery, expectedMessageKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("saml redirect signature: canonicalLen=%d", len(signedPayload))
|
||||
|
||||
signature, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
|
||||
certs, err := getIDPCertificates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cert := range certs {
|
||||
log.Printf("saml redirect signature: certThumbprint=%s", certificateThumbprintSHA1(cert))
|
||||
if err := verifyRedirectSignature(cert.PublicKey, sigAlg, signedPayload, signature); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
|
||||
func buildRedirectSignaturePayload(rawQuery, expectedMessageKey string) ([]byte, error) {
|
||||
var samlReq, samlResp, relay, sigAlg string
|
||||
for _, part := range strings.Split(rawQuery, "&") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key, val, found := strings.Cut(part, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "SAMLRequest":
|
||||
if samlReq == "" {
|
||||
samlReq = val
|
||||
}
|
||||
case "SAMLResponse":
|
||||
if samlResp == "" {
|
||||
samlResp = val
|
||||
}
|
||||
case "RelayState":
|
||||
if relay == "" {
|
||||
relay = val
|
||||
}
|
||||
case "SigAlg":
|
||||
if sigAlg == "" {
|
||||
sigAlg = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msgKey := "SAMLRequest"
|
||||
msgVal := samlReq
|
||||
if msgVal == "" {
|
||||
msgKey = "SAMLResponse"
|
||||
msgVal = samlResp
|
||||
}
|
||||
if expectedMessageKey != "" && msgKey != expectedMessageKey {
|
||||
return nil, fmt.Errorf("missing %s", expectedMessageKey)
|
||||
}
|
||||
if msgVal == "" || sigAlg == "" {
|
||||
return nil, fmt.Errorf("missing SAML payload or SigAlg")
|
||||
}
|
||||
|
||||
payload := msgKey + "=" + msgVal
|
||||
if relay != "" {
|
||||
payload += "&RelayState=" + relay
|
||||
}
|
||||
payload += "&SigAlg=" + sigAlg
|
||||
return []byte(payload), nil
|
||||
}
|
||||
|
||||
func validateLogoutResponseSignature(xmlBytes []byte) error {
|
||||
return validateLogoutRequestSignature(xmlBytes)
|
||||
}
|
||||
|
||||
func hasXMLSignature(xmlBytes []byte) bool {
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(xmlBytes); err != nil {
|
||||
return false
|
||||
}
|
||||
root := doc.Root()
|
||||
if root == nil {
|
||||
return false
|
||||
}
|
||||
for _, el := range root.FindElements(".//*") {
|
||||
if strings.EqualFold(el.Tag, "Signature") || strings.HasSuffix(el.Tag, ":Signature") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func certificateThumbprintSHA1(cert *x509.Certificate) string {
|
||||
if cert == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(cert.Raw)
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func validateSAMLMessageIssuer(issuer string) error {
|
||||
issuer = strings.TrimSpace(issuer)
|
||||
if issuer == "" {
|
||||
return fmt.Errorf("missing issuer")
|
||||
}
|
||||
if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil {
|
||||
return fmt.Errorf("missing idp metadata")
|
||||
}
|
||||
expected := strings.TrimSpace(SAMLMiddleware.ServiceProvider.IDPMetadata.EntityID)
|
||||
if expected == "" {
|
||||
return fmt.Errorf("missing expected issuer")
|
||||
}
|
||||
if issuer != expected {
|
||||
return fmt.Errorf("unexpected issuer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupUserAuthSession(c *fiber.Ctx, nameID string) {
|
||||
if strings.TrimSpace(nameID) != "" {
|
||||
_ = DeleteSessionByNameID(nameID)
|
||||
}
|
||||
if token := strings.TrimSpace(c.Cookies("session_token")); token != "" {
|
||||
_ = DeleteSession(token)
|
||||
}
|
||||
clearSessionCookie(c, h.cfg.CookieDomain, h.cfg.CookieSecure)
|
||||
if h.auth == nil {
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimSpace(c.Cookies(h.auth.AccessCookieName()))
|
||||
refreshToken := strings.TrimSpace(c.Cookies(h.auth.RefreshCookieName()))
|
||||
if accessToken != "" {
|
||||
if userID, err := h.auth.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
|
||||
_ = h.auth.RevokeAllUserSessions(c.UserContext(), userID)
|
||||
log.Printf("logout cleanup: revoked app sessions user_id=%x", userID)
|
||||
}
|
||||
}
|
||||
if refreshToken != "" {
|
||||
_ = h.auth.RevokeRefreshToken(c.UserContext(), refreshToken)
|
||||
}
|
||||
clearAuthCookies(c, h.auth)
|
||||
}
|
||||
|
||||
func clearSessionCookie(c *fiber.Ctx, domain string, secure bool) {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: domain,
|
||||
HTTPOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: "lax",
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: svc.AccessCookieName(),
|
||||
Value: "",
|
||||
HTTPOnly: true,
|
||||
Secure: svc.CookieSecure(),
|
||||
SameSite: parseSameSite(svc.CookieSameSite()),
|
||||
Domain: svc.CookieDomain(),
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: svc.RefreshCookieName(),
|
||||
Value: "",
|
||||
HTTPOnly: true,
|
||||
Secure: svc.CookieSecure(),
|
||||
SameSite: parseSameSite(svc.CookieSameSite()),
|
||||
Domain: svc.CookieDomain(),
|
||||
Path: "/api/v1/auth/refresh",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) logoutRedirectURL() string {
|
||||
base := strings.TrimRight(strings.TrimSpace(h.cfg.FrontendURL), "/")
|
||||
if base == "" {
|
||||
return "/signin?loggedOut=true"
|
||||
}
|
||||
return base + "/signin?loggedOut=true"
|
||||
}
|
||||
|
||||
func verifyRedirectSignature(pub any, sigAlg string, payload, signature []byte) error {
|
||||
switch sigAlg {
|
||||
case "http://www.w3.org/2000/09/xmldsig#rsa-sha1":
|
||||
sum := sha1.Sum(payload)
|
||||
key, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected key type %T", pub)
|
||||
}
|
||||
return rsa.VerifyPKCS1v15(key, crypto.SHA1, sum[:], signature)
|
||||
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256":
|
||||
sum := sha256.Sum256(payload)
|
||||
key, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected key type %T", pub)
|
||||
}
|
||||
return rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature)
|
||||
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384":
|
||||
sum := sha512.Sum384(payload)
|
||||
key, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected key type %T", pub)
|
||||
}
|
||||
return rsa.VerifyPKCS1v15(key, crypto.SHA384, sum[:], signature)
|
||||
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512":
|
||||
sum := sha512.Sum512(payload)
|
||||
key, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected key type %T", pub)
|
||||
}
|
||||
return rsa.VerifyPKCS1v15(key, crypto.SHA512, sum[:], signature)
|
||||
case "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256":
|
||||
sum := sha256.Sum256(payload)
|
||||
key, ok := pub.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected key type %T", pub)
|
||||
}
|
||||
if ecdsa.VerifyASN1(key, sum[:], signature) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ecdsa verify failed")
|
||||
default:
|
||||
return fmt.Errorf("unsupported SigAlg %q", sigAlg)
|
||||
}
|
||||
}
|
||||
|
||||
func formOrQuery(c *fiber.Ctx, key string) string {
|
||||
if v := strings.TrimSpace(c.FormValue(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(c.Query(key))
|
||||
}
|
||||
|
||||
func decodeSAMLMessage(raw string) ([]byte, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bytes.Contains(decoded, []byte("<")) {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
reader := flate.NewReader(bytes.NewReader(decoded))
|
||||
defer reader.Close()
|
||||
var out bytes.Buffer
|
||||
if _, err := out.ReadFrom(reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func samlResponseStatus(raw string) (string, string) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
var resp saml.Response
|
||||
if err := xml.Unmarshal(decoded, &resp); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if resp.Status.StatusCode.Value == "" && resp.Status.StatusMessage == nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
statusMessage := ""
|
||||
if resp.Status.StatusMessage != nil {
|
||||
statusMessage = strings.TrimSpace(resp.Status.StatusMessage.Value)
|
||||
}
|
||||
return resp.Status.StatusCode.Value, statusMessage
|
||||
}
|
||||
|
||||
func readDeviceTypeHint(c *fiber.Ctx) string {
|
||||
return strings.TrimSpace(c.Get("X-Device-Type"))
|
||||
}
|
||||
|
||||
func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
refreshTTL := tokens.RefreshTTL
|
||||
if refreshTTL <= 0 {
|
||||
refreshTTL = svc.RefreshTTL()
|
||||
}
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: svc.AccessCookieName(),
|
||||
Value: tokens.AccessToken,
|
||||
HTTPOnly: true,
|
||||
Secure: svc.CookieSecure(),
|
||||
SameSite: parseSameSite(svc.CookieSameSite()),
|
||||
Domain: svc.CookieDomain(),
|
||||
Path: "/",
|
||||
MaxAge: int(svc.AccessTTL().Seconds()),
|
||||
})
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: svc.RefreshCookieName(),
|
||||
Value: tokens.RefreshToken,
|
||||
HTTPOnly: true,
|
||||
Secure: svc.CookieSecure(),
|
||||
SameSite: parseSameSite(svc.CookieSameSite()),
|
||||
Domain: svc.CookieDomain(),
|
||||
Path: "/api/v1/auth/refresh",
|
||||
MaxAge: int(refreshTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func parseSameSite(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "none":
|
||||
return "none"
|
||||
case "strict":
|
||||
return "strict"
|
||||
default:
|
||||
return "lax"
|
||||
}
|
||||
}
|
||||
|
||||
func wantsJSON(c *fiber.Ctx) bool {
|
||||
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
|
||||
return strings.Contains(accept, "application/json") || strings.Contains(accept, "application/vnd.api+json")
|
||||
}
|
||||
257
internal/auth/handler_logout_test.go
Normal file
257
internal/auth/handler_logout_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/crewjam/saml/samlsp"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"wucher/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleBrowserLocalLogout_RedirectAndClearSessionCookie(t *testing.T) {
|
||||
prev := SAMLMiddleware
|
||||
SAMLMiddleware = nil
|
||||
t.Cleanup(func() { SAMLMiddleware = prev })
|
||||
|
||||
h := NewHandler(config.Config{
|
||||
FrontendURL: "https://wfm-dev.mybit.co.id",
|
||||
CookieDomain: ".mybit.co.id",
|
||||
CookieSecure: true,
|
||||
}, nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleBrowserLocalLogout)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
|
||||
t.Fatalf("unexpected redirect: %s", got)
|
||||
}
|
||||
|
||||
setCookie := strings.Join(resp.Header.Values("Set-Cookie"), "\n")
|
||||
if !strings.Contains(setCookie, "session_token=") {
|
||||
t.Fatalf("expected session_token clear cookie, got %q", setCookie)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(setCookie), "domain=.mybit.co.id") {
|
||||
t.Fatalf("expected cookie domain .mybit.co.id, got %q", setCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedirectSignaturePayload_PreservesRawEncoding(t *testing.T) {
|
||||
raw := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=ignored"
|
||||
got, err := buildRedirectSignaturePayload(raw, "SAMLResponse")
|
||||
if err != nil {
|
||||
t.Fatalf("build payload: %v", err)
|
||||
}
|
||||
want := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256"
|
||||
if string(got) != want {
|
||||
t.Fatalf("payload mismatch\nwant: %s\ngot: %s", want, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithRelayState(t *testing.T) {
|
||||
runValidSAMLResponseRedirectTest(t, "relay-state-1", true)
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithoutRelayState(t *testing.T) {
|
||||
runValidSAMLResponseRedirectTest(t, "", false)
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Invalid(t *testing.T) {
|
||||
priv, cert := testSigningCert(t)
|
||||
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
|
||||
|
||||
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
|
||||
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
||||
params := []string{
|
||||
"SAMLResponse=" + url.QueryEscape(xmlB64),
|
||||
"SigAlg=" + url.QueryEscape(sigAlg),
|
||||
}
|
||||
canonical := strings.Join(params, "&")
|
||||
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
|
||||
// Break signature on purpose
|
||||
sig = strings.TrimRight(sig, "=") + "A"
|
||||
|
||||
path := "/api/v1/auth/microsoft/logout?" + canonical + "&Signature=" + url.QueryEscape(sig)
|
||||
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSAMLLogoutResponse_GetMissingSignature(t *testing.T) {
|
||||
_, cert := testSigningCert(t)
|
||||
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
|
||||
|
||||
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
|
||||
path := "/api/v1/auth/microsoft/logout?SAMLResponse=" + url.QueryEscape(xmlB64)
|
||||
|
||||
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func runValidSAMLResponseRedirectTest(t *testing.T, relayState string, withRelay bool) {
|
||||
t.Helper()
|
||||
priv, cert := testSigningCert(t)
|
||||
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
|
||||
|
||||
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
|
||||
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
|
||||
|
||||
parts := []string{"SAMLResponse=" + url.QueryEscape(xmlB64)}
|
||||
if withRelay {
|
||||
parts = append(parts, "RelayState="+url.QueryEscape(relayState))
|
||||
}
|
||||
parts = append(parts, "SigAlg="+url.QueryEscape(sigAlg))
|
||||
canonical := strings.Join(parts, "&")
|
||||
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
|
||||
rawQuery := canonical + "&Signature=" + url.QueryEscape(sig)
|
||||
|
||||
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout?"+rawQuery, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
|
||||
t.Fatalf("unexpected redirect: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func signRedirectPayloadRSA256(t *testing.T, priv *rsa.PrivateKey, payload []byte) string {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(payload)
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign payload: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func testSigningCert(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "test-idp"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
return priv, cert
|
||||
}
|
||||
|
||||
func setTestSAMLMiddlewareWithCert(t *testing.T, cert *x509.Certificate, entityID string) {
|
||||
t.Helper()
|
||||
prev := SAMLMiddleware
|
||||
SAMLMiddleware = &samlsp.Middleware{}
|
||||
SAMLMiddleware.ServiceProvider.IDPMetadata = &saml.EntityDescriptor{
|
||||
EntityID: entityID,
|
||||
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
||||
{
|
||||
SSODescriptor: saml.SSODescriptor{
|
||||
RoleDescriptor: saml.RoleDescriptor{
|
||||
KeyDescriptors: []saml.KeyDescriptor{
|
||||
{
|
||||
KeyInfo: dsigKeyInfoFromCert(cert),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() { SAMLMiddleware = prev })
|
||||
}
|
||||
|
||||
func dsigKeyInfoFromCert(cert *x509.Certificate) saml.KeyInfo {
|
||||
if cert == nil {
|
||||
return saml.KeyInfo{}
|
||||
}
|
||||
return saml.KeyInfo{
|
||||
X509Data: saml.X509Data{
|
||||
X509Certificates: []saml.X509Certificate{
|
||||
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testLogoutResponseXML(issuer string) string {
|
||||
return fmt.Sprintf(
|
||||
`<LogoutResponse xmlns="urn:oasis:names:tc:SAML:2.0:protocol" ID="_id123" Version="2.0" IssueInstant="2026-05-28T00:00:00Z"><Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">%s</Issuer><Status><StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></Status></LogoutResponse>`,
|
||||
issuer,
|
||||
)
|
||||
}
|
||||
|
||||
func TestCertificateThumbprintSHA1(t *testing.T) {
|
||||
_, cert := testSigningCert(t)
|
||||
got := certificateThumbprintSHA1(cert)
|
||||
if got == "" {
|
||||
t.Fatal("thumbprint should not be empty")
|
||||
}
|
||||
sum := sha1.Sum(cert.Raw)
|
||||
want := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
if got != want {
|
||||
t.Fatalf("thumbprint mismatch: got=%s want=%s", got, want)
|
||||
}
|
||||
}
|
||||
124
internal/auth/saml.go
Normal file
124
internal/auth/saml.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/crewjam/saml/samlsp"
|
||||
"wucher/internal/config"
|
||||
)
|
||||
|
||||
var SAMLMiddleware *samlsp.Middleware
|
||||
|
||||
func InitSAML(cfg config.Config) error {
|
||||
keyPair, err := tls.LoadX509KeyPair(cfg.SAMLCertFile, cfg.SAMLKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load saml keypair: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(keyPair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse saml certificate: %w", err)
|
||||
}
|
||||
signer, ok := keyPair.PrivateKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return fmt.Errorf("saml private key is not a crypto.Signer")
|
||||
}
|
||||
|
||||
idpMetadataBytes, err := os.ReadFile(cfg.SAMLMetadataFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read idp metadata: %w", err)
|
||||
}
|
||||
|
||||
var idpMetadata saml.EntityDescriptor
|
||||
if err := xml.Unmarshal(idpMetadataBytes, &idpMetadata); err != nil {
|
||||
return fmt.Errorf("parse idp metadata xml: %w", err)
|
||||
}
|
||||
|
||||
rootURL, err := url.Parse(cfg.SAMLRootURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SAML_ROOT_URL: %w", err)
|
||||
}
|
||||
|
||||
m, err := samlsp.New(samlsp.Options{
|
||||
EntityID: cfg.SAMLRootURL,
|
||||
URL: *rootURL,
|
||||
Key: signer,
|
||||
Certificate: cert,
|
||||
IDPMetadata: &idpMetadata,
|
||||
AllowIDPInitiated: true,
|
||||
CookieSameSite: http.SameSiteLaxMode,
|
||||
DefaultRedirectURI: cfg.FrontendURL + "/dashboard",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("init saml middleware: %w", err)
|
||||
}
|
||||
|
||||
m.ServiceProvider.MetadataURL.Path = "/api/v1/auth/saml/metadata"
|
||||
m.ServiceProvider.AcsURL.Path = "/api/v1/auth/microsoft/callback"
|
||||
m.ServiceProvider.SloURL.Path = "/api/v1/auth/microsoft/logout"
|
||||
m.ServiceProvider.ValidateAudienceRestriction = func(assertion *saml.Assertion) error {
|
||||
if assertion == nil || assertion.Conditions == nil || len(assertion.Conditions.AudienceRestrictions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
expected := strings.TrimRight(firstNonEmpty(m.ServiceProvider.EntityID, m.ServiceProvider.MetadataURL.String()), "/")
|
||||
for _, audienceRestriction := range assertion.Conditions.AudienceRestrictions {
|
||||
if strings.TrimRight(audienceRestriction.Audience.Value, "/") == expected {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("assertion Conditions AudienceRestriction does not contain %q", expected)
|
||||
}
|
||||
SAMLMiddleware = m
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIDPCertificates() ([]*x509.Certificate, error) {
|
||||
if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil {
|
||||
return nil, fmt.Errorf("saml middleware not initialized")
|
||||
}
|
||||
|
||||
var certificates []*x509.Certificate
|
||||
for _, idp := range SAMLMiddleware.ServiceProvider.IDPMetadata.IDPSSODescriptors {
|
||||
for _, kd := range idp.KeyDescriptors {
|
||||
for _, certData := range kd.KeyInfo.X509Data.X509Certificates {
|
||||
raw := strings.TrimSpace(certData.Data)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
certificates = append(certificates, cert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(certificates) == 0 {
|
||||
return nil, fmt.Errorf("no idp certificate in metadata")
|
||||
}
|
||||
return certificates, nil
|
||||
}
|
||||
127
internal/auth/session.go
Normal file
127
internal/auth/session.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserSession struct {
|
||||
UserID string `json:"user_id"`
|
||||
MicrosoftNameID string `json:"microsoft_name_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
LoggedInAt time.Time `json:"logged_in_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type SessionRecord struct {
|
||||
Token string `gorm:"type:varchar(128);primaryKey;column:token"`
|
||||
UserID string `gorm:"type:varchar(191);index;not null;column:user_id"`
|
||||
MicrosoftNameID string `gorm:"type:varchar(255);index;not null;column:microsoft_name_id"`
|
||||
Email string `gorm:"type:varchar(191);index;not null;column:email"`
|
||||
Name string `gorm:"type:varchar(191);not null;column:name"`
|
||||
LoggedInAt time.Time `gorm:"not null;column:logged_in_at"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (SessionRecord) TableName() string { return "saml_sessions" }
|
||||
|
||||
var sessionDB *gorm.DB
|
||||
|
||||
func InitSessionStoreDB(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is required")
|
||||
}
|
||||
if err := db.AutoMigrate(&SessionRecord{}); err != nil {
|
||||
return fmt.Errorf("auto migrate saml_sessions: %w", err)
|
||||
}
|
||||
sessionDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateSession(session UserSession) (string, error) {
|
||||
if sessionDB == nil {
|
||||
return "", fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if session.LoggedInAt.IsZero() {
|
||||
session.LoggedInAt = time.Now().UTC()
|
||||
}
|
||||
if session.ExpiresAt.IsZero() {
|
||||
session.ExpiresAt = session.LoggedInAt.Add(8 * time.Hour)
|
||||
}
|
||||
|
||||
record := SessionRecord{
|
||||
Token: token,
|
||||
UserID: session.UserID,
|
||||
MicrosoftNameID: session.MicrosoftNameID,
|
||||
Email: session.Email,
|
||||
Name: session.Name,
|
||||
LoggedInAt: session.LoggedInAt,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
if err := sessionDB.Create(&record).Error; err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func GetSession(token string) (*UserSession, error) {
|
||||
if sessionDB == nil {
|
||||
return nil, fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
var record SessionRecord
|
||||
err := sessionDB.Where("token = ?", token).First(&record).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !record.ExpiresAt.After(now) {
|
||||
_ = sessionDB.Delete(&SessionRecord{}, "token = ?", token).Error
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &UserSession{
|
||||
UserID: record.UserID,
|
||||
MicrosoftNameID: record.MicrosoftNameID,
|
||||
Email: record.Email,
|
||||
Name: record.Name,
|
||||
LoggedInAt: record.LoggedInAt,
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DeleteSession(token string) error {
|
||||
if sessionDB == nil {
|
||||
return fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
return sessionDB.Delete(&SessionRecord{}, "token = ?", token).Error
|
||||
}
|
||||
|
||||
func DeleteSessionByNameID(nameID string) error {
|
||||
if sessionDB == nil {
|
||||
return fmt.Errorf("session store is not initialized")
|
||||
}
|
||||
return sessionDB.Delete(&SessionRecord{}, "microsoft_name_id = ?", nameID).Error
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
if token == "" {
|
||||
return "", errors.New("empty token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
7
internal/buildinfo/buildinfo.go
Normal file
7
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
928
internal/config/config.go
Normal file
928
internal/config/config.go
Normal file
@@ -0,0 +1,928 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppPort string
|
||||
AppEnv string
|
||||
|
||||
SAMLCertFile string
|
||||
SAMLKeyFile string
|
||||
SAMLMetadataFile string
|
||||
SAMLRootURL string
|
||||
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
|
||||
FrontendURL string
|
||||
|
||||
CookieDomain string
|
||||
CookieSecure bool
|
||||
SessionTTLHour int
|
||||
}
|
||||
|
||||
type SAMLConfig struct {
|
||||
CertFile string
|
||||
KeyFile string
|
||||
MetadataFile string
|
||||
RootURL string
|
||||
FrontendURL string
|
||||
CookieDomain string
|
||||
CookieSecure bool
|
||||
SessionTTLHr int
|
||||
}
|
||||
|
||||
func LoadEnvConfig() Config {
|
||||
return Config{
|
||||
AppPort: envString("APP_PORT", "3000"),
|
||||
AppEnv: envString("APP_ENV", "development"),
|
||||
SAMLCertFile: envString("SAML_CERT_FILE", "configs/saml.crt"),
|
||||
SAMLKeyFile: envString("SAML_KEY_FILE", "configs/saml.key"),
|
||||
SAMLMetadataFile: envString("SAML_METADATA_FILE", "configs/metadata.xml"),
|
||||
SAMLRootURL: envString("SAML_ROOT_URL", "https://api-wfm-dev.mybit.co.id"),
|
||||
RedisAddr: envString("REDIS_ADDR", "localhost:6379"),
|
||||
RedisPassword: envString("REDIS_PASSWORD", ""),
|
||||
RedisDB: envInt("REDIS_DB", 0),
|
||||
FrontendURL: envString("FRONTEND_URL", "https://wfm-dev.mybit.co.id"),
|
||||
CookieDomain: strings.TrimSpace(os.Getenv("COOKIE_DOMAIN")),
|
||||
CookieSecure: envBool("COOKIE_SECURE", true),
|
||||
SessionTTLHour: envInt("SESSION_TTL_HOUR", 8),
|
||||
}
|
||||
}
|
||||
|
||||
func envString(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
DSN string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnMaxIdleTime time.Duration
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
PublicBaseURL string
|
||||
PasswordPepper string
|
||||
IPEncryptionKey string
|
||||
HashTime uint32
|
||||
HashMemoryKB uint32
|
||||
HashThreads uint8
|
||||
HashKeyLen uint32
|
||||
EmailVerifyTTL time.Duration
|
||||
EmailVerifyURL string
|
||||
InviteTTL time.Duration
|
||||
SetPasswordURL string
|
||||
PasswordResetTTL time.Duration
|
||||
PasswordResetURL string
|
||||
SecurityPINResetTTL time.Duration
|
||||
SecurityPINResetURL string
|
||||
ForgotSecurityPINCooldown time.Duration
|
||||
ForgotSecurityPINMinDuration time.Duration
|
||||
SecurityPINMaxAttempts int
|
||||
SecurityPINLockDuration time.Duration
|
||||
SecurityPINActionTokenTTL time.Duration
|
||||
ForgotPasswordEmailCooldown time.Duration
|
||||
ForgotPasswordMinDuration time.Duration
|
||||
ForgotPasswordIPRateMax int
|
||||
ForgotPasswordIPRateWindow time.Duration
|
||||
LoginIPRateMax int
|
||||
LoginIPRateWindow time.Duration
|
||||
TOTPChallengeTTL time.Duration
|
||||
EmailOTPChallengeTTL time.Duration
|
||||
EmailOTPLength int
|
||||
EmailOTPExpiry time.Duration
|
||||
EmailOTPMaxAttempts int
|
||||
EmailOTPResendCooldown time.Duration
|
||||
EmailOTPMaxResendPerHour int
|
||||
SSOStateTTL time.Duration
|
||||
TOTPIssuer string
|
||||
TOTPSecretKeyB64 string
|
||||
JWTAccessSecret string
|
||||
JWTRefreshSecret string
|
||||
JWTAccessTTL time.Duration
|
||||
JWTRefreshTTL time.Duration
|
||||
JWTRefreshTOTPTTL time.Duration
|
||||
JWTLocalRefreshTTL time.Duration
|
||||
JWTSSORefreshTTL time.Duration
|
||||
SessionIdleTTL time.Duration
|
||||
JWTCookieDomain string
|
||||
JWTCookieSecure bool
|
||||
JWTCookieSameSite string
|
||||
JWTAccessCookieName string
|
||||
JWTRefreshCookieName string
|
||||
WebAuthnRPID string
|
||||
WebAuthnRPDisplayName string
|
||||
WebAuthnRPOrigins []string
|
||||
WebAuthnChallengeTTL time.Duration
|
||||
SSOSuccessRedirectURL string
|
||||
DefaultRoleName string
|
||||
DisableRegister bool
|
||||
}
|
||||
|
||||
type SESConfig struct {
|
||||
Region string
|
||||
Endpoint string
|
||||
From string
|
||||
ConfigurationSetName string
|
||||
SendTimeout time.Duration
|
||||
AllowInsecureEndpoint bool
|
||||
}
|
||||
|
||||
type EmailDeliveryConfig struct {
|
||||
Provider string
|
||||
MockDir string
|
||||
}
|
||||
|
||||
type MicrosoftSSOConfig struct {
|
||||
TenantID string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Authority string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
BodyLimit int
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
RequestTimeoutByPath map[string]time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
CORSAllowOrigins string
|
||||
CORSAllowMethods string
|
||||
CORSAllowHeaders string
|
||||
CORSExposeHeaders string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int
|
||||
RateLimitMax int
|
||||
RateLimitWindow time.Duration
|
||||
RateLimitByEndpoint map[string]RateLimitRule
|
||||
HSTSMaxAge int
|
||||
}
|
||||
|
||||
type RateLimitRule struct {
|
||||
Max int
|
||||
Window time.Duration
|
||||
}
|
||||
|
||||
type AuditConfig struct {
|
||||
QueueSize int
|
||||
Workers int
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string
|
||||
Format string
|
||||
}
|
||||
|
||||
type CircuitBreakerPolicy struct {
|
||||
Enabled bool
|
||||
MaxRequests uint32
|
||||
Interval time.Duration
|
||||
BucketPeriod time.Duration
|
||||
Timeout time.Duration
|
||||
MinRequests uint32
|
||||
FailureRatio float64
|
||||
ConsecutiveFailures uint32
|
||||
}
|
||||
|
||||
type ResilienceConfig struct {
|
||||
Default CircuitBreakerPolicy
|
||||
MicrosoftSSO CircuitBreakerPolicy
|
||||
SES CircuitBreakerPolicy
|
||||
SQS CircuitBreakerPolicy
|
||||
}
|
||||
|
||||
type QueueConfig struct {
|
||||
MessageSchemaVersion string
|
||||
EnableLegacyPayloadFallback bool
|
||||
IdempotencyKeyPrefix string
|
||||
IdempotencyTTL time.Duration
|
||||
ProcessingTTL time.Duration
|
||||
WorkerConcurrency int
|
||||
WorkerMaxBatchSize int
|
||||
WorkerProcessTimeout time.Duration
|
||||
WorkerShutdownTimeout time.Duration
|
||||
WorkerBackoffMin time.Duration
|
||||
WorkerBackoffMax time.Duration
|
||||
WorkerPermanentFailureDelay time.Duration
|
||||
WorkerMonitorAddr string
|
||||
WorkerHealthErrorThreshold int
|
||||
WorkerDepthPollInterval time.Duration
|
||||
OutboxEnabled bool
|
||||
OutboxPollInterval time.Duration
|
||||
OutboxBatchSize int
|
||||
OutboxDispatchTimeout time.Duration
|
||||
OutboxLockTTL time.Duration
|
||||
OutboxMaxAttempts int
|
||||
}
|
||||
|
||||
type FileQueueConfig struct {
|
||||
MessageSchemaVersion string
|
||||
IdempotencyKeyPrefix string
|
||||
IdempotencyTTL time.Duration
|
||||
ProcessingTTL time.Duration
|
||||
WorkerConcurrency int
|
||||
WorkerMaxBatchSize int
|
||||
WorkerProcessTimeout time.Duration
|
||||
WorkerShutdownTimeout time.Duration
|
||||
WorkerBackoffMin time.Duration
|
||||
WorkerBackoffMax time.Duration
|
||||
WorkerPermanentFailureDelay time.Duration
|
||||
WorkerMonitorAddr string
|
||||
WorkerHealthErrorThreshold int
|
||||
WorkerDepthPollInterval time.Duration
|
||||
OutboxEnabled bool
|
||||
OutboxPollInterval time.Duration
|
||||
OutboxBatchSize int
|
||||
OutboxDispatchTimeout time.Duration
|
||||
OutboxLockTTL time.Duration
|
||||
OutboxMaxAttempts int
|
||||
}
|
||||
|
||||
type SQSConfig struct {
|
||||
Region string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
Endpoint string
|
||||
QueueURL string
|
||||
QueueType string
|
||||
MessageGroupID string
|
||||
MaxMessages int
|
||||
WaitTimeSeconds int32
|
||||
VisibilityTimeout time.Duration
|
||||
AllowInsecureEndpoint bool
|
||||
}
|
||||
|
||||
type FileStorageConfig struct {
|
||||
Provider string
|
||||
Bucket string
|
||||
Region string
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
ForcePathStyle bool
|
||||
AllowInsecureEndpoint bool
|
||||
AutoCreateBucket bool
|
||||
ObjectKeyPrefix string
|
||||
PresignPutTTL time.Duration
|
||||
PresignGetTTL time.Duration
|
||||
}
|
||||
|
||||
type FileManagerUploadPolicyConfig struct {
|
||||
MaxFileSizeBytes int64
|
||||
AllowedMimeTypes []string
|
||||
AllowedExtensions []string
|
||||
AllowEmptyFile bool
|
||||
MaxFilenameLength int
|
||||
}
|
||||
|
||||
type FileManagerTrashPolicyConfig struct {
|
||||
AutoPurgeEnabled bool
|
||||
Retention time.Duration
|
||||
AutoPurgeInterval time.Duration
|
||||
AutoPurgeBatchSize int
|
||||
}
|
||||
|
||||
type FileManagerLifecycleConfig struct {
|
||||
EnableS3Tagging bool
|
||||
EnableCleanupJob bool
|
||||
TempUploadGraceHours int
|
||||
OrphanGraceDays int
|
||||
CleanupBatchSize int
|
||||
CleanupDryRun bool
|
||||
}
|
||||
|
||||
type WOPIConfig struct {
|
||||
TokenSecret string
|
||||
ProofSecret string
|
||||
DiscoveryURL string
|
||||
PublicBaseURL string
|
||||
EditActionURL string
|
||||
TokenTTL time.Duration
|
||||
MaxBodyBytes int
|
||||
RequireProof bool
|
||||
}
|
||||
|
||||
type GotenbergConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
func LoadMySQLConfigFromEnv() MySQLConfig {
|
||||
return MySQLConfig{
|
||||
DSN: getenv("MYSQL_DSN", ""),
|
||||
MaxOpenConns: getenvInt("MYSQL_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getenvInt("MYSQL_MAX_IDLE_CONNS", 10),
|
||||
ConnMaxLifetime: getenvDuration("MYSQL_CONN_MAX_LIFETIME", 5*time.Minute),
|
||||
ConnMaxIdleTime: getenvDuration("MYSQL_CONN_MAX_IDLE_TIME", 2*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadAuthConfigFromEnv() AuthConfig {
|
||||
refreshTTL := getenvDuration("AUTH_JWT_REFRESH_TTL", time.Hour)
|
||||
refreshTOTPTTL := getenvDuration("AUTH_JWT_REFRESH_TOTP_TTL", refreshTTL)
|
||||
passwordPepper := getenv("AUTH_PASSWORD_PEPPER", "")
|
||||
ipEncryptionKey := getenv("AUTH_IP_ENCRYPTION_KEY", getenv("AUTH_IP_HMAC_KEY", passwordPepper))
|
||||
|
||||
return AuthConfig{
|
||||
PublicBaseURL: getenv("APP_PUBLIC_BASE_URL", ""),
|
||||
PasswordPepper: passwordPepper,
|
||||
IPEncryptionKey: ipEncryptionKey,
|
||||
HashTime: uint32(getenvInt("AUTH_HASH_TIME", 1)),
|
||||
HashMemoryKB: uint32(getenvInt("AUTH_HASH_MEMORY_KB", 64*1024)),
|
||||
HashThreads: uint8(getenvInt("AUTH_HASH_THREADS", 4)),
|
||||
HashKeyLen: uint32(getenvInt("AUTH_HASH_KEY_LEN", 32)),
|
||||
EmailVerifyTTL: getenvDuration("AUTH_EMAIL_VERIFY_TTL", 24*time.Hour),
|
||||
EmailVerifyURL: getenv("AUTH_EMAIL_VERIFY_URL", ""),
|
||||
InviteTTL: getenvDuration("AUTH_INVITE_TTL", 24*time.Hour),
|
||||
SetPasswordURL: getenv("AUTH_SET_PASSWORD_URL", getenv("AUTH_EMAIL_VERIFY_URL", "")),
|
||||
PasswordResetTTL: getenvDuration("AUTH_PASSWORD_RESET_TTL", 20*time.Minute),
|
||||
PasswordResetURL: getenv("AUTH_PASSWORD_RESET_URL", getenv("AUTH_SET_PASSWORD_URL", "")),
|
||||
SecurityPINResetTTL: getenvDuration("AUTH_SECURITY_PIN_RESET_TTL", 20*time.Minute),
|
||||
SecurityPINResetURL: getenv("AUTH_SECURITY_PIN_RESET_URL", getenv("AUTH_PASSWORD_RESET_URL", getenv("AUTH_SET_PASSWORD_URL", ""))),
|
||||
ForgotSecurityPINCooldown: getenvDuration("AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN", 2*time.Minute),
|
||||
ForgotSecurityPINMinDuration: getenvDuration("AUTH_FORGOT_SECURITY_PIN_MIN_DURATION", 150*time.Millisecond),
|
||||
SecurityPINMaxAttempts: getenvInt("AUTH_SECURITY_PIN_MAX_ATTEMPTS", 5),
|
||||
SecurityPINLockDuration: getenvDuration("AUTH_SECURITY_PIN_LOCK_DURATION", 0),
|
||||
SecurityPINActionTokenTTL: getenvDuration("AUTH_SECURITY_PIN_ACTION_TOKEN_TTL", 5*time.Minute),
|
||||
ForgotPasswordEmailCooldown: getenvDuration("AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN", 2*time.Minute),
|
||||
ForgotPasswordMinDuration: getenvDuration("AUTH_FORGOT_PASSWORD_MIN_DURATION", 150*time.Millisecond),
|
||||
ForgotPasswordIPRateMax: getenvInt("AUTH_FORGOT_PASSWORD_IP_RATE_MAX", 10),
|
||||
ForgotPasswordIPRateWindow: getenvDuration("AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW", 15*time.Minute),
|
||||
LoginIPRateMax: getenvInt("AUTH_LOGIN_IP_RATE_MAX", 5),
|
||||
LoginIPRateWindow: getenvDuration("AUTH_LOGIN_IP_RATE_WINDOW", time.Minute),
|
||||
TOTPChallengeTTL: getenvDuration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute),
|
||||
EmailOTPChallengeTTL: getenvDuration("AUTH_EMAIL_OTP_CHALLENGE_TTL", 10*time.Minute),
|
||||
EmailOTPLength: getenvInt("AUTH_EMAIL_OTP_LENGTH", 6),
|
||||
EmailOTPExpiry: getenvDuration("AUTH_EMAIL_OTP_EXPIRY", 10*time.Minute),
|
||||
EmailOTPMaxAttempts: getenvInt("AUTH_EMAIL_OTP_MAX_ATTEMPTS", 5),
|
||||
EmailOTPResendCooldown: getenvDuration("AUTH_EMAIL_OTP_RESEND_COOLDOWN", 60*time.Second),
|
||||
EmailOTPMaxResendPerHour: getenvInt("AUTH_EMAIL_OTP_MAX_RESEND_PER_HOUR", 5),
|
||||
SSOStateTTL: getenvDuration("AUTH_SSO_STATE_TTL", 10*time.Minute),
|
||||
TOTPIssuer: getenv("TOTP_ISSUER", "Wucher"),
|
||||
TOTPSecretKeyB64: getenv("TOTP_SECRET_KEY", ""),
|
||||
JWTAccessSecret: getenv("AUTH_JWT_ACCESS_SECRET", ""),
|
||||
JWTRefreshSecret: getenv("AUTH_JWT_REFRESH_SECRET", ""),
|
||||
JWTAccessTTL: getenvDuration("AUTH_JWT_ACCESS_TTL", 10*time.Minute),
|
||||
JWTRefreshTTL: refreshTTL,
|
||||
JWTRefreshTOTPTTL: refreshTOTPTTL,
|
||||
JWTLocalRefreshTTL: getenvDuration("AUTH_JWT_LOCAL_REFRESH_TTL", 24*time.Hour),
|
||||
// Fallback lifetime for Microsoft/SSO sessions when Microsoft does not
|
||||
// report refresh_token_expires_in. Defaults to Microsoft's default
|
||||
// refresh-token lifetime (90 days); the actual session follows the
|
||||
// value Microsoft returns whenever it is present.
|
||||
JWTSSORefreshTTL: getenvDuration("AUTH_JWT_SSO_REFRESH_TTL", 90*24*time.Hour),
|
||||
SessionIdleTTL: getenvDuration("AUTH_SESSION_IDLE_TTL", 15*time.Minute),
|
||||
JWTCookieDomain: getenv("AUTH_JWT_COOKIE_DOMAIN", ""),
|
||||
JWTCookieSecure: getenvBool("AUTH_JWT_COOKIE_SECURE", false),
|
||||
JWTCookieSameSite: getenv("AUTH_JWT_COOKIE_SAMESITE", "Lax"),
|
||||
JWTAccessCookieName: getenv("AUTH_JWT_ACCESS_COOKIE", "wucher_at"),
|
||||
JWTRefreshCookieName: getenv("AUTH_JWT_REFRESH_COOKIE", "wucher_rt"),
|
||||
WebAuthnRPID: getenv("AUTH_WEBAUTHN_RP_ID", ""),
|
||||
WebAuthnRPDisplayName: getenv("AUTH_WEBAUTHN_RP_DISPLAY_NAME", "Wucher"),
|
||||
WebAuthnRPOrigins: getenvCSV("AUTH_WEBAUTHN_RP_ORIGINS", ""),
|
||||
WebAuthnChallengeTTL: getenvDuration("AUTH_WEBAUTHN_CHALLENGE_TTL", 5*time.Minute),
|
||||
SSOSuccessRedirectURL: getenv("AUTH_SSO_SUCCESS_REDIRECT", ""),
|
||||
DefaultRoleName: getenv("AUTH_DEFAULT_ROLE", "user"),
|
||||
DisableRegister: getenvBool("AUTH_DISABLE_REGISTER", false),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadSESConfigFromEnv() SESConfig {
|
||||
return SESConfig{
|
||||
Region: getenv("SES_REGION", getenv("AWS_REGION", "")),
|
||||
Endpoint: getenv("SES_ENDPOINT", ""),
|
||||
From: getenv("SES_FROM", ""),
|
||||
ConfigurationSetName: getenv("SES_CONFIGURATION_SET", ""),
|
||||
SendTimeout: getenvDuration("SES_SEND_TIMEOUT", 10*time.Second),
|
||||
AllowInsecureEndpoint: getenvBool("SES_ALLOW_INSECURE_ENDPOINT", false),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadEmailDeliveryConfigFromEnv() EmailDeliveryConfig {
|
||||
return EmailDeliveryConfig{
|
||||
Provider: strings.ToLower(strings.TrimSpace(getenv("EMAIL_PROVIDER", "ses"))),
|
||||
MockDir: strings.TrimSpace(getenv("EMAIL_MOCK_DIR", ".local/emails")),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadMicrosoftSSOConfigFromEnv() MicrosoftSSOConfig {
|
||||
scopes := strings.Split(getenv("MS_ENTRA_SCOPES", "openid,profile,email,offline_access,User.Read"), ",")
|
||||
for i := range scopes {
|
||||
scopes[i] = strings.TrimSpace(scopes[i])
|
||||
}
|
||||
return MicrosoftSSOConfig{
|
||||
TenantID: getenv("MS_ENTRA_TENANT_ID", ""),
|
||||
ClientID: getenv("MS_ENTRA_CLIENT_ID", ""),
|
||||
ClientSecret: getenv("MS_ENTRA_CLIENT_SECRET", ""),
|
||||
RedirectURL: getenv("MS_ENTRA_REDIRECT_URL", ""),
|
||||
Authority: getenv("MS_ENTRA_AUTHORITY", "https://login.microsoftonline.com/"),
|
||||
Scopes: scopes,
|
||||
}
|
||||
}
|
||||
|
||||
func LoadHTTPConfigFromEnv() HTTPConfig {
|
||||
return HTTPConfig{
|
||||
BodyLimit: getenvInt("HTTP_BODY_LIMIT", 4*1024*1024),
|
||||
ReadTimeout: getenvDuration("HTTP_READ_TIMEOUT", 10*time.Second),
|
||||
WriteTimeout: getenvDuration("HTTP_WRITE_TIMEOUT", 15*time.Second),
|
||||
IdleTimeout: getenvDuration("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
||||
RequestTimeout: getenvDuration("HTTP_REQUEST_TIMEOUT", 30*time.Second),
|
||||
RequestTimeoutByPath: parseHTTPTimeoutOverrides(getenv("HTTP_REQUEST_TIMEOUT_OVERRIDES", "")),
|
||||
ShutdownTimeout: getenvDuration("HTTP_SHUTDOWN_TIMEOUT", 30*time.Second),
|
||||
CORSAllowOrigins: getenv("HTTP_CORS_ALLOW_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173"),
|
||||
CORSAllowMethods: getenv("HTTP_CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD"),
|
||||
CORSAllowHeaders: getenv("HTTP_CORS_ALLOW_HEADERS", "Origin,Content-Type,Accept,Authorization,X-Requested-With,X-Pin-Verification-Token,X-Device-Type"),
|
||||
CORSExposeHeaders: getenv("HTTP_CORS_EXPOSE_HEADERS", "X-Request-ID"),
|
||||
CORSAllowCredentials: getenvBool("HTTP_CORS_ALLOW_CREDENTIALS", true),
|
||||
CORSMaxAge: getenvInt("HTTP_CORS_MAX_AGE", 300),
|
||||
RateLimitMax: getenvInt("HTTP_RATE_LIMIT_MAX", 120),
|
||||
RateLimitWindow: getenvDuration("HTTP_RATE_LIMIT_WINDOW", time.Minute),
|
||||
RateLimitByEndpoint: parseHTTPEndpointRateLimitOverrides(getenv("HTTP_RATE_LIMIT_ENDPOINTS", "")),
|
||||
HSTSMaxAge: getenvInt("HTTP_SECURITY_HSTS_MAX_AGE", 0),
|
||||
}
|
||||
}
|
||||
|
||||
func parseHTTPTimeoutOverrides(raw string) map[string]time.Duration {
|
||||
parsed := make(map[string]time.Duration)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return parsed
|
||||
}
|
||||
|
||||
entries := strings.Split(raw, ",")
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
keyParts := strings.Fields(strings.TrimSpace(parts[0]))
|
||||
if len(keyParts) < 2 {
|
||||
continue
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(keyParts[0]))
|
||||
path := strings.Join(keyParts[1:], " ")
|
||||
path = strings.TrimSpace(path)
|
||||
if method == "" || path == "" {
|
||||
continue
|
||||
}
|
||||
key := method + " " + path
|
||||
|
||||
duration, err := time.ParseDuration(strings.TrimSpace(parts[1]))
|
||||
if err != nil || duration <= 0 {
|
||||
continue
|
||||
}
|
||||
parsed[key] = duration
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseHTTPEndpointRateLimitOverrides(raw string) map[string]RateLimitRule {
|
||||
parsed := make(map[string]RateLimitRule)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return parsed
|
||||
}
|
||||
|
||||
entries := strings.Split(raw, ";")
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
keyParts := strings.Fields(strings.TrimSpace(parts[0]))
|
||||
if len(keyParts) < 2 {
|
||||
continue
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(keyParts[0]))
|
||||
path := strings.Join(keyParts[1:], " ")
|
||||
path = strings.TrimSpace(path)
|
||||
if method == "" || path == "" {
|
||||
continue
|
||||
}
|
||||
key := method + " " + path
|
||||
|
||||
ruleParts := strings.SplitN(strings.TrimSpace(parts[1]), ",", 2)
|
||||
if len(ruleParts) != 2 {
|
||||
continue
|
||||
}
|
||||
max, err := strconv.Atoi(strings.TrimSpace(ruleParts[0]))
|
||||
if err != nil || max <= 0 {
|
||||
continue
|
||||
}
|
||||
window, err := time.ParseDuration(strings.TrimSpace(ruleParts[1]))
|
||||
if err != nil || window <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed[key] = RateLimitRule{
|
||||
Max: max,
|
||||
Window: window,
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
func LoadAuditConfigFromEnv() AuditConfig {
|
||||
return AuditConfig{
|
||||
QueueSize: getenvInt("AUDIT_LOG_QUEUE_SIZE", 1024),
|
||||
Workers: getenvInt("AUDIT_LOG_WORKERS", 2),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadLoggingConfigFromEnv() LoggingConfig {
|
||||
return LoggingConfig{
|
||||
Level: getenv("LOG_LEVEL", "info"),
|
||||
Format: getenv("LOG_FORMAT", "json"),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadResilienceConfigFromEnv() ResilienceConfig {
|
||||
defaults := loadCircuitBreakerPolicyFromEnv("CB_DEFAULT_", CircuitBreakerPolicy{
|
||||
Enabled: true,
|
||||
MaxRequests: 1,
|
||||
Interval: time.Minute,
|
||||
BucketPeriod: 10 * time.Second,
|
||||
Timeout: 30 * time.Second,
|
||||
MinRequests: 5,
|
||||
FailureRatio: 0.6,
|
||||
ConsecutiveFailures: 5,
|
||||
})
|
||||
return ResilienceConfig{
|
||||
Default: defaults,
|
||||
MicrosoftSSO: loadCircuitBreakerPolicyFromEnv("CB_MICROSOFT_SSO_", defaults),
|
||||
SES: loadCircuitBreakerPolicyFromEnv("CB_SES_", defaults),
|
||||
SQS: loadCircuitBreakerPolicyFromEnv("CB_SQS_", defaults),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadQueueConfigFromEnv() QueueConfig {
|
||||
return QueueConfig{
|
||||
MessageSchemaVersion: getenv("QUEUE_MESSAGE_SCHEMA_VERSION", "v2"),
|
||||
EnableLegacyPayloadFallback: getenvBool("QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK", true),
|
||||
IdempotencyKeyPrefix: getenv("QUEUE_IDEMPOTENCY_KEY_PREFIX", "queue:idempotency:email:"),
|
||||
IdempotencyTTL: getenvDuration("QUEUE_IDEMPOTENCY_TTL", 24*time.Hour),
|
||||
ProcessingTTL: getenvDuration("QUEUE_PROCESSING_TTL", 15*time.Minute),
|
||||
WorkerConcurrency: getenvInt("QUEUE_WORKER_CONCURRENCY", 4),
|
||||
WorkerMaxBatchSize: getenvInt("QUEUE_WORKER_MAX_BATCH_SIZE", 10),
|
||||
WorkerProcessTimeout: getenvDuration("QUEUE_WORKER_PROCESS_TIMEOUT", 30*time.Second),
|
||||
WorkerShutdownTimeout: getenvDuration("QUEUE_WORKER_SHUTDOWN_TIMEOUT", 30*time.Second),
|
||||
WorkerBackoffMin: getenvDuration("QUEUE_WORKER_BACKOFF_MIN", time.Second),
|
||||
WorkerBackoffMax: getenvDuration("QUEUE_WORKER_BACKOFF_MAX", 5*time.Minute),
|
||||
WorkerPermanentFailureDelay: getenvDuration("QUEUE_WORKER_PERMANENT_FAILURE_DELAY", 30*time.Second),
|
||||
WorkerMonitorAddr: getenv("QUEUE_WORKER_MONITOR_ADDR", "127.0.0.1:9090"),
|
||||
WorkerHealthErrorThreshold: getenvInt("QUEUE_WORKER_HEALTH_ERROR_THRESHOLD", 3),
|
||||
WorkerDepthPollInterval: getenvDuration("QUEUE_WORKER_DEPTH_POLL_INTERVAL", 30*time.Second),
|
||||
OutboxEnabled: getenvBool("QUEUE_OUTBOX_ENABLED", true),
|
||||
OutboxPollInterval: getenvDuration("QUEUE_OUTBOX_POLL_INTERVAL", time.Second),
|
||||
OutboxBatchSize: getenvInt("QUEUE_OUTBOX_BATCH_SIZE", 10),
|
||||
OutboxDispatchTimeout: getenvDuration("QUEUE_OUTBOX_DISPATCH_TIMEOUT", 10*time.Second),
|
||||
OutboxLockTTL: getenvDuration("QUEUE_OUTBOX_LOCK_TTL", time.Minute),
|
||||
OutboxMaxAttempts: getenvInt("QUEUE_OUTBOX_MAX_ATTEMPTS", 20),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFileQueueConfigFromEnv() FileQueueConfig {
|
||||
return FileQueueConfig{
|
||||
MessageSchemaVersion: getenv("FILE_QUEUE_MESSAGE_SCHEMA_VERSION", "v1"),
|
||||
IdempotencyKeyPrefix: getenv("FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX", "queue:idempotency:file:"),
|
||||
IdempotencyTTL: getenvDuration("FILE_QUEUE_IDEMPOTENCY_TTL", 24*time.Hour),
|
||||
ProcessingTTL: getenvDuration("FILE_QUEUE_PROCESSING_TTL", 15*time.Minute),
|
||||
WorkerConcurrency: getenvInt("FILE_QUEUE_WORKER_CONCURRENCY", 4),
|
||||
WorkerMaxBatchSize: getenvInt("FILE_QUEUE_WORKER_MAX_BATCH_SIZE", 10),
|
||||
WorkerProcessTimeout: getenvDuration("FILE_QUEUE_WORKER_PROCESS_TIMEOUT", 30*time.Second),
|
||||
WorkerShutdownTimeout: getenvDuration("FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT", 30*time.Second),
|
||||
WorkerBackoffMin: getenvDuration("FILE_QUEUE_WORKER_BACKOFF_MIN", time.Second),
|
||||
WorkerBackoffMax: getenvDuration("FILE_QUEUE_WORKER_BACKOFF_MAX", 5*time.Minute),
|
||||
WorkerPermanentFailureDelay: getenvDuration("FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY", 30*time.Second),
|
||||
WorkerMonitorAddr: getenv("FILE_QUEUE_WORKER_MONITOR_ADDR", "127.0.0.1:9091"),
|
||||
WorkerHealthErrorThreshold: getenvInt("FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD", 3),
|
||||
WorkerDepthPollInterval: getenvDuration("FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL", 30*time.Second),
|
||||
OutboxEnabled: getenvBool("FILE_QUEUE_OUTBOX_ENABLED", true),
|
||||
OutboxPollInterval: getenvDuration("FILE_QUEUE_OUTBOX_POLL_INTERVAL", time.Second),
|
||||
OutboxBatchSize: getenvInt("FILE_QUEUE_OUTBOX_BATCH_SIZE", 10),
|
||||
OutboxDispatchTimeout: getenvDuration("FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT", 10*time.Second),
|
||||
OutboxLockTTL: getenvDuration("FILE_QUEUE_OUTBOX_LOCK_TTL", time.Minute),
|
||||
OutboxMaxAttempts: getenvInt("FILE_QUEUE_OUTBOX_MAX_ATTEMPTS", 20),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadSQSConfigFromEnv() SQSConfig {
|
||||
return SQSConfig{
|
||||
Region: getenv("AWS_REGION", ""),
|
||||
AccessKeyID: getenv("AWS_ACCESS_KEY_ID", ""),
|
||||
SecretAccessKey: getenv("AWS_SECRET_ACCESS_KEY", ""),
|
||||
Endpoint: getenv("SQS_ENDPOINT", ""),
|
||||
QueueURL: getenv("SQS_QUEUE_URL", ""),
|
||||
QueueType: getenv("SQS_QUEUE_TYPE", "standard"),
|
||||
MessageGroupID: getenv("SQS_MESSAGE_GROUP_ID", "email"),
|
||||
MaxMessages: getenvInt("SQS_MAX_MESSAGES", 10),
|
||||
WaitTimeSeconds: int32(getenvInt("SQS_WAIT_TIME_SECONDS", 20)),
|
||||
VisibilityTimeout: getenvDuration("SQS_VISIBILITY_TIMEOUT", 60*time.Second),
|
||||
AllowInsecureEndpoint: getenvBool("SQS_ALLOW_INSECURE_ENDPOINT", false),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFileSQSConfigFromEnv() SQSConfig {
|
||||
return SQSConfig{
|
||||
Region: getenv("FILE_SQS_REGION", getenv("AWS_REGION", "")),
|
||||
AccessKeyID: getenv("AWS_ACCESS_KEY_ID", ""),
|
||||
SecretAccessKey: getenv("AWS_SECRET_ACCESS_KEY", ""),
|
||||
Endpoint: getenv("FILE_SQS_ENDPOINT", getenv("SQS_ENDPOINT", "")),
|
||||
QueueURL: getenv("FILE_SQS_QUEUE_URL", ""),
|
||||
QueueType: getenv("FILE_SQS_QUEUE_TYPE", "standard"),
|
||||
MessageGroupID: getenv("FILE_SQS_MESSAGE_GROUP_ID", "file-processing"),
|
||||
MaxMessages: getenvInt("FILE_SQS_MAX_MESSAGES", 10),
|
||||
WaitTimeSeconds: int32(getenvInt("FILE_SQS_WAIT_TIME_SECONDS", 20)),
|
||||
VisibilityTimeout: getenvDuration("FILE_SQS_VISIBILITY_TIMEOUT", 60*time.Second),
|
||||
AllowInsecureEndpoint: getenvBool("FILE_SQS_ALLOW_INSECURE_ENDPOINT", getenvBool("SQS_ALLOW_INSECURE_ENDPOINT", false)),
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateDistinctQueueURLs(emailCfg, fileCfg SQSConfig) error {
|
||||
emailQueueURL := canonicalQueueURL(emailCfg.QueueURL)
|
||||
fileQueueURL := canonicalQueueURL(fileCfg.QueueURL)
|
||||
|
||||
if emailQueueURL == "" || fileQueueURL == "" {
|
||||
return nil
|
||||
}
|
||||
if emailQueueURL == fileQueueURL {
|
||||
return fmt.Errorf("invalid queue configuration: FILE_SQS_QUEUE_URL must be different from SQS_QUEUE_URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalQueueURL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return strings.TrimRight(strings.ToLower(trimmed), "/")
|
||||
}
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func LoadFileStorageConfigFromEnv() FileStorageConfig {
|
||||
provider := strings.ToLower(strings.TrimSpace(getenv("FILE_STORAGE_PROVIDER", "s3")))
|
||||
return FileStorageConfig{
|
||||
Provider: provider,
|
||||
Bucket: strings.TrimSpace(getenv("FILE_S3_BUCKET", "")),
|
||||
Region: strings.TrimSpace(getenv("FILE_S3_REGION", getenv("AWS_REGION", ""))),
|
||||
Endpoint: strings.TrimSpace(getenv("FILE_S3_ENDPOINT", "")),
|
||||
AccessKeyID: strings.TrimSpace(getenv("FILE_S3_ACCESS_KEY_ID", getenv("AWS_ACCESS_KEY_ID", ""))),
|
||||
SecretAccessKey: strings.TrimSpace(getenv("FILE_S3_SECRET_ACCESS_KEY", getenv("AWS_SECRET_ACCESS_KEY", ""))),
|
||||
ForcePathStyle: getenvBool("FILE_S3_FORCE_PATH_STYLE", false),
|
||||
AllowInsecureEndpoint: getenvBool("FILE_S3_ALLOW_INSECURE_ENDPOINT", false),
|
||||
AutoCreateBucket: getenvBool("FILE_S3_AUTO_CREATE_BUCKET", false),
|
||||
ObjectKeyPrefix: strings.TrimSpace(getenv("FILE_OBJECT_KEY_PREFIX", "fm/objects")),
|
||||
PresignPutTTL: getenvDuration("FILE_S3_PRESIGN_PUT_TTL", 15*time.Minute),
|
||||
PresignGetTTL: getenvDuration("FILE_S3_PRESIGN_GET_TTL", 15*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFileManagerUploadPolicyConfigFromEnv() FileManagerUploadPolicyConfig {
|
||||
return FileManagerUploadPolicyConfig{
|
||||
MaxFileSizeBytes: int64(getenvInt("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", 4*1024*1024)),
|
||||
AllowedMimeTypes: normalizeMimeTypes(getenvCSV("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", "")),
|
||||
AllowedExtensions: normalizeExtensions(getenvCSV("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", "")),
|
||||
AllowEmptyFile: getenvBool("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", false),
|
||||
MaxFilenameLength: getenvInt("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", 255),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFileManagerTrashPolicyConfigFromEnv() FileManagerTrashPolicyConfig {
|
||||
retention := getenvDuration("FILE_MANAGER_TRASH_RETENTION", 30*24*time.Hour)
|
||||
if retention < 0 {
|
||||
retention = 0
|
||||
}
|
||||
interval := getenvDuration("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", time.Hour)
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
batchSize := getenvInt("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", 200)
|
||||
if batchSize <= 0 {
|
||||
batchSize = 200
|
||||
}
|
||||
|
||||
return FileManagerTrashPolicyConfig{
|
||||
AutoPurgeEnabled: getenvBool("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", true),
|
||||
Retention: retention,
|
||||
AutoPurgeInterval: interval,
|
||||
AutoPurgeBatchSize: batchSize,
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFileManagerLifecycleConfigFromEnv() FileManagerLifecycleConfig {
|
||||
tempHours := getenvInt("TEMP_UPLOAD_GRACE_HOURS", 2)
|
||||
if tempHours <= 0 {
|
||||
tempHours = 2
|
||||
}
|
||||
orphanDays := getenvInt("ORPHAN_GRACE_PERIOD_DAYS", 30)
|
||||
if orphanDays <= 0 {
|
||||
orphanDays = 30
|
||||
}
|
||||
batchSize := getenvInt("FILE_CLEANUP_BATCH_SIZE", 100)
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
return FileManagerLifecycleConfig{
|
||||
EnableS3Tagging: getenvBool("ENABLE_S3_TAGGING", true),
|
||||
EnableCleanupJob: getenvBool("ENABLE_FILE_CLEANUP_JOB", false),
|
||||
TempUploadGraceHours: tempHours,
|
||||
OrphanGraceDays: orphanDays,
|
||||
CleanupBatchSize: batchSize,
|
||||
CleanupDryRun: getenvBool("FILE_CLEANUP_DRY_RUN", true),
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvUint32(key string, def uint32) uint32 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
return uint32(n)
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvFloat64(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvDuration(key string, def time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvBool(key string, def bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvCSV(key, def string) []string {
|
||||
raw := os.Getenv(key)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
raw = def
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for i := range parts {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeMimeTypes(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for i := range values {
|
||||
v := strings.ToLower(strings.TrimSpace(values[i]))
|
||||
if idx := strings.Index(v, ";"); idx >= 0 {
|
||||
v = strings.TrimSpace(v[:idx])
|
||||
}
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeExtensions(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for i := range values {
|
||||
v := strings.ToLower(strings.TrimSpace(values[i]))
|
||||
v = strings.TrimPrefix(v, ".")
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadCircuitBreakerPolicyFromEnv(prefix string, defaults CircuitBreakerPolicy) CircuitBreakerPolicy {
|
||||
return CircuitBreakerPolicy{
|
||||
Enabled: getenvBool(prefix+"ENABLED", defaults.Enabled),
|
||||
MaxRequests: getenvUint32(prefix+"MAX_REQUESTS", defaults.MaxRequests),
|
||||
Interval: getenvDuration(prefix+"INTERVAL", defaults.Interval),
|
||||
BucketPeriod: getenvDuration(prefix+"BUCKET_PERIOD", defaults.BucketPeriod),
|
||||
Timeout: getenvDuration(prefix+"TIMEOUT", defaults.Timeout),
|
||||
MinRequests: getenvUint32(prefix+"MIN_REQUESTS", defaults.MinRequests),
|
||||
FailureRatio: getenvFloat64(prefix+"FAILURE_RATIO", defaults.FailureRatio),
|
||||
ConsecutiveFailures: getenvUint32(prefix+"CONSECUTIVE_FAILURES", defaults.ConsecutiveFailures),
|
||||
}
|
||||
}
|
||||
340
internal/config/config_test.go
Normal file
340
internal/config/config_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadFileManagerUploadPolicyConfigFromEnv_Defaults(t *testing.T) {
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", "")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", "")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", "")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", "")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", "")
|
||||
|
||||
cfg := LoadFileManagerUploadPolicyConfigFromEnv()
|
||||
|
||||
if cfg.MaxFileSizeBytes != 4*1024*1024 {
|
||||
t.Fatalf("expected default max file size 4194304, got %d", cfg.MaxFileSizeBytes)
|
||||
}
|
||||
if cfg.AllowEmptyFile {
|
||||
t.Fatalf("expected default allow empty file false")
|
||||
}
|
||||
if cfg.MaxFilenameLength != 255 {
|
||||
t.Fatalf("expected default max filename length 255, got %d", cfg.MaxFilenameLength)
|
||||
}
|
||||
if len(cfg.AllowedMimeTypes) != 0 {
|
||||
t.Fatalf("expected empty default allowed mime types, got %v", cfg.AllowedMimeTypes)
|
||||
}
|
||||
if len(cfg.AllowedExtensions) != 0 {
|
||||
t.Fatalf("expected empty default allowed extensions, got %v", cfg.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileManagerUploadPolicyConfigFromEnv_Normalize(t *testing.T) {
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", "1048576")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", " image/PNG ; charset=utf-8,application/pdf,IMAGE/PNG")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", " .PDF, png ,PNG")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", "true")
|
||||
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", "120")
|
||||
|
||||
cfg := LoadFileManagerUploadPolicyConfigFromEnv()
|
||||
|
||||
if cfg.MaxFileSizeBytes != 1048576 {
|
||||
t.Fatalf("expected max file size 1048576, got %d", cfg.MaxFileSizeBytes)
|
||||
}
|
||||
if !cfg.AllowEmptyFile {
|
||||
t.Fatalf("expected allow empty file true")
|
||||
}
|
||||
if cfg.MaxFilenameLength != 120 {
|
||||
t.Fatalf("expected max filename length 120, got %d", cfg.MaxFilenameLength)
|
||||
}
|
||||
|
||||
if len(cfg.AllowedMimeTypes) != 2 || cfg.AllowedMimeTypes[0] != "image/png" || cfg.AllowedMimeTypes[1] != "application/pdf" {
|
||||
t.Fatalf("unexpected normalized mime types: %v", cfg.AllowedMimeTypes)
|
||||
}
|
||||
if len(cfg.AllowedExtensions) != 2 || cfg.AllowedExtensions[0] != "pdf" || cfg.AllowedExtensions[1] != "png" {
|
||||
t.Fatalf("unexpected normalized extensions: %v", cfg.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileManagerTrashPolicyConfigFromEnv_Defaults(t *testing.T) {
|
||||
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", "")
|
||||
t.Setenv("FILE_MANAGER_TRASH_RETENTION", "")
|
||||
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", "")
|
||||
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", "")
|
||||
|
||||
cfg := LoadFileManagerTrashPolicyConfigFromEnv()
|
||||
if !cfg.AutoPurgeEnabled {
|
||||
t.Fatalf("expected default auto purge enabled")
|
||||
}
|
||||
if cfg.Retention != 30*24*time.Hour {
|
||||
t.Fatalf("expected default retention 720h, got %s", cfg.Retention)
|
||||
}
|
||||
if cfg.AutoPurgeInterval != time.Hour {
|
||||
t.Fatalf("expected default auto purge interval 1h, got %s", cfg.AutoPurgeInterval)
|
||||
}
|
||||
if cfg.AutoPurgeBatchSize != 200 {
|
||||
t.Fatalf("expected default batch size 200, got %d", cfg.AutoPurgeBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileManagerTrashPolicyConfigFromEnv_Custom(t *testing.T) {
|
||||
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", "false")
|
||||
t.Setenv("FILE_MANAGER_TRASH_RETENTION", "168h")
|
||||
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", "15m")
|
||||
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", "50")
|
||||
|
||||
cfg := LoadFileManagerTrashPolicyConfigFromEnv()
|
||||
if cfg.AutoPurgeEnabled {
|
||||
t.Fatalf("expected auto purge disabled")
|
||||
}
|
||||
if cfg.Retention != 168*time.Hour {
|
||||
t.Fatalf("expected retention 168h, got %s", cfg.Retention)
|
||||
}
|
||||
if cfg.AutoPurgeInterval != 15*time.Minute {
|
||||
t.Fatalf("expected auto purge interval 15m, got %s", cfg.AutoPurgeInterval)
|
||||
}
|
||||
if cfg.AutoPurgeBatchSize != 50 {
|
||||
t.Fatalf("expected batch size 50, got %d", cfg.AutoPurgeBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileManagerLifecycleConfigFromEnv_Defaults(t *testing.T) {
|
||||
t.Setenv("ENABLE_S3_TAGGING", "")
|
||||
t.Setenv("ENABLE_FILE_CLEANUP_JOB", "")
|
||||
t.Setenv("TEMP_UPLOAD_GRACE_HOURS", "")
|
||||
t.Setenv("ORPHAN_GRACE_PERIOD_DAYS", "")
|
||||
t.Setenv("FILE_CLEANUP_BATCH_SIZE", "")
|
||||
t.Setenv("FILE_CLEANUP_DRY_RUN", "")
|
||||
|
||||
cfg := LoadFileManagerLifecycleConfigFromEnv()
|
||||
if !cfg.EnableS3Tagging {
|
||||
t.Fatalf("expected s3 tagging enabled by default")
|
||||
}
|
||||
if cfg.EnableCleanupJob {
|
||||
t.Fatalf("expected cleanup job disabled by default")
|
||||
}
|
||||
if cfg.TempUploadGraceHours != 2 {
|
||||
t.Fatalf("expected temp upload grace 2 hours, got %d", cfg.TempUploadGraceHours)
|
||||
}
|
||||
if cfg.OrphanGraceDays != 30 {
|
||||
t.Fatalf("expected orphan grace 30 days, got %d", cfg.OrphanGraceDays)
|
||||
}
|
||||
if cfg.CleanupBatchSize != 100 {
|
||||
t.Fatalf("expected cleanup batch size 100, got %d", cfg.CleanupBatchSize)
|
||||
}
|
||||
if !cfg.CleanupDryRun {
|
||||
t.Fatalf("expected cleanup dry run enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileManagerLifecycleConfigFromEnv_Custom(t *testing.T) {
|
||||
t.Setenv("ENABLE_S3_TAGGING", "false")
|
||||
t.Setenv("ENABLE_FILE_CLEANUP_JOB", "true")
|
||||
t.Setenv("TEMP_UPLOAD_GRACE_HOURS", "6")
|
||||
t.Setenv("ORPHAN_GRACE_PERIOD_DAYS", "45")
|
||||
t.Setenv("FILE_CLEANUP_BATCH_SIZE", "250")
|
||||
t.Setenv("FILE_CLEANUP_DRY_RUN", "false")
|
||||
|
||||
cfg := LoadFileManagerLifecycleConfigFromEnv()
|
||||
if cfg.EnableS3Tagging {
|
||||
t.Fatalf("expected s3 tagging disabled")
|
||||
}
|
||||
if !cfg.EnableCleanupJob {
|
||||
t.Fatalf("expected cleanup job enabled")
|
||||
}
|
||||
if cfg.TempUploadGraceHours != 6 {
|
||||
t.Fatalf("expected temp upload grace 6 hours, got %d", cfg.TempUploadGraceHours)
|
||||
}
|
||||
if cfg.OrphanGraceDays != 45 {
|
||||
t.Fatalf("expected orphan grace 45 days, got %d", cfg.OrphanGraceDays)
|
||||
}
|
||||
if cfg.CleanupBatchSize != 250 {
|
||||
t.Fatalf("expected cleanup batch size 250, got %d", cfg.CleanupBatchSize)
|
||||
}
|
||||
if cfg.CleanupDryRun {
|
||||
t.Fatalf("expected cleanup dry run disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileQueueConfigFromEnv_Defaults(t *testing.T) {
|
||||
t.Setenv("FILE_QUEUE_MESSAGE_SCHEMA_VERSION", "")
|
||||
t.Setenv("FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX", "")
|
||||
t.Setenv("FILE_QUEUE_WORKER_MONITOR_ADDR", "")
|
||||
t.Setenv("FILE_QUEUE_OUTBOX_ENABLED", "")
|
||||
t.Setenv("FILE_QUEUE_OUTBOX_POLL_INTERVAL", "")
|
||||
t.Setenv("FILE_QUEUE_OUTBOX_BATCH_SIZE", "")
|
||||
t.Setenv("FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT", "")
|
||||
t.Setenv("FILE_QUEUE_OUTBOX_LOCK_TTL", "")
|
||||
t.Setenv("FILE_QUEUE_OUTBOX_MAX_ATTEMPTS", "")
|
||||
|
||||
cfg := LoadFileQueueConfigFromEnv()
|
||||
if cfg.MessageSchemaVersion != "v1" {
|
||||
t.Fatalf("expected default schema version v1, got %q", cfg.MessageSchemaVersion)
|
||||
}
|
||||
if cfg.IdempotencyKeyPrefix != "queue:idempotency:file:" {
|
||||
t.Fatalf("unexpected default idempotency prefix: %q", cfg.IdempotencyKeyPrefix)
|
||||
}
|
||||
if cfg.WorkerMonitorAddr != "127.0.0.1:9091" {
|
||||
t.Fatalf("unexpected default monitor addr: %q", cfg.WorkerMonitorAddr)
|
||||
}
|
||||
if !cfg.OutboxEnabled {
|
||||
t.Fatalf("expected default file outbox enabled")
|
||||
}
|
||||
if cfg.OutboxPollInterval != time.Second {
|
||||
t.Fatalf("expected default outbox poll interval 1s, got %s", cfg.OutboxPollInterval)
|
||||
}
|
||||
if cfg.OutboxBatchSize != 10 {
|
||||
t.Fatalf("expected default outbox batch size 10, got %d", cfg.OutboxBatchSize)
|
||||
}
|
||||
if cfg.OutboxDispatchTimeout != 10*time.Second {
|
||||
t.Fatalf("expected default outbox dispatch timeout 10s, got %s", cfg.OutboxDispatchTimeout)
|
||||
}
|
||||
if cfg.OutboxLockTTL != time.Minute {
|
||||
t.Fatalf("expected default outbox lock ttl 1m, got %s", cfg.OutboxLockTTL)
|
||||
}
|
||||
if cfg.OutboxMaxAttempts != 20 {
|
||||
t.Fatalf("expected default outbox max attempts 20, got %d", cfg.OutboxMaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileSQSConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("AWS_REGION", "ap-southeast-1")
|
||||
t.Setenv("SQS_ENDPOINT", "http://localhost:4566")
|
||||
t.Setenv("SQS_ALLOW_INSECURE_ENDPOINT", "true")
|
||||
t.Setenv("FILE_SQS_REGION", "")
|
||||
t.Setenv("FILE_SQS_ENDPOINT", "")
|
||||
t.Setenv("FILE_SQS_QUEUE_URL", "http://localhost:4566/000000000000/app-file-queue")
|
||||
t.Setenv("FILE_SQS_QUEUE_TYPE", "standard")
|
||||
t.Setenv("FILE_SQS_MESSAGE_GROUP_ID", "file")
|
||||
t.Setenv("FILE_SQS_MAX_MESSAGES", "8")
|
||||
t.Setenv("FILE_SQS_WAIT_TIME_SECONDS", "15")
|
||||
t.Setenv("FILE_SQS_VISIBILITY_TIMEOUT", "90s")
|
||||
t.Setenv("FILE_SQS_ALLOW_INSECURE_ENDPOINT", "")
|
||||
|
||||
cfg := LoadFileSQSConfigFromEnv()
|
||||
if cfg.Region != "ap-southeast-1" {
|
||||
t.Fatalf("unexpected region: %q", cfg.Region)
|
||||
}
|
||||
if cfg.Endpoint != "http://localhost:4566" {
|
||||
t.Fatalf("unexpected endpoint: %q", cfg.Endpoint)
|
||||
}
|
||||
if cfg.QueueURL == "" {
|
||||
t.Fatalf("expected queue url")
|
||||
}
|
||||
if cfg.MaxMessages != 8 {
|
||||
t.Fatalf("unexpected max messages: %d", cfg.MaxMessages)
|
||||
}
|
||||
if cfg.WaitTimeSeconds != 15 {
|
||||
t.Fatalf("unexpected wait time seconds: %d", cfg.WaitTimeSeconds)
|
||||
}
|
||||
if !cfg.AllowInsecureEndpoint {
|
||||
t.Fatalf("expected insecure endpoint allowed from fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDistinctQueueURLs(t *testing.T) {
|
||||
t.Run("different queue urls", func(t *testing.T) {
|
||||
err := ValidateDistinctQueueURLs(
|
||||
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue"},
|
||||
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-file-queue"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same queue urls with different case and slash", func(t *testing.T) {
|
||||
err := ValidateDistinctQueueURLs(
|
||||
SQSConfig{QueueURL: "HTTP://LOCALHOST:4566/000000000000/app-queue"},
|
||||
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue/"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for same queue urls")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skip when one queue url empty", func(t *testing.T) {
|
||||
err := ValidateDistinctQueueURLs(
|
||||
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue"},
|
||||
SQSConfig{QueueURL: ""},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error when file queue url is empty, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseHTTPTimeoutOverrides(t *testing.T) {
|
||||
t.Run("invalid format ignored", func(t *testing.T) {
|
||||
parsed := parseHTTPTimeoutOverrides("GET /users/:id 100ms,foo")
|
||||
if len(parsed) != 0 {
|
||||
t.Fatalf("expected no parsed entries, got %v", parsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid duration ignored", func(t *testing.T) {
|
||||
parsed := parseHTTPTimeoutOverrides("GET /users/:id=not-a-duration")
|
||||
if len(parsed) != 0 {
|
||||
t.Fatalf("expected no parsed entries, got %v", parsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero and negative duration ignored", func(t *testing.T) {
|
||||
parsed := parseHTTPTimeoutOverrides("GET /users/:id=0s,POST /users=-1s")
|
||||
if len(parsed) != 0 {
|
||||
t.Fatalf("expected no parsed entries, got %v", parsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid entries parsed", func(t *testing.T) {
|
||||
parsed := parseHTTPTimeoutOverrides("GET /users/:id=120ms, POST /auth/login = 2s")
|
||||
if len(parsed) != 2 {
|
||||
t.Fatalf("expected 2 parsed entries, got %d", len(parsed))
|
||||
}
|
||||
if parsed["GET /users/:id"] != 120*time.Millisecond {
|
||||
t.Fatalf("unexpected GET /users/:id timeout: %s", parsed["GET /users/:id"])
|
||||
}
|
||||
if parsed["POST /auth/login"] != 2*time.Second {
|
||||
t.Fatalf("unexpected POST /auth/login timeout: %s", parsed["POST /auth/login"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseHTTPEndpointRateLimitOverrides(t *testing.T) {
|
||||
t.Run("invalid entries ignored", func(t *testing.T) {
|
||||
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=abc,1m;foo;GET /x=1")
|
||||
if len(parsed) != 0 {
|
||||
t.Fatalf("expected no parsed entries, got %v", parsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero or negative values ignored", func(t *testing.T) {
|
||||
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=0,1m;POST /auth/totp/verify=5,0s")
|
||||
if len(parsed) != 0 {
|
||||
t.Fatalf("expected no parsed entries, got %v", parsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid entries parsed", func(t *testing.T) {
|
||||
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=5,1m; POST /auth/totp/verify = 3,30s")
|
||||
if len(parsed) != 2 {
|
||||
t.Fatalf("expected 2 parsed entries, got %d", len(parsed))
|
||||
}
|
||||
login, ok := parsed["POST /auth/login"]
|
||||
if !ok {
|
||||
t.Fatalf("expected POST /auth/login to be parsed")
|
||||
}
|
||||
if login.Max != 5 || login.Window != time.Minute {
|
||||
t.Fatalf("unexpected login rule: %+v", login)
|
||||
}
|
||||
totp, ok := parsed["POST /auth/totp/verify"]
|
||||
if !ok {
|
||||
t.Fatalf("expected POST /auth/totp/verify to be parsed")
|
||||
}
|
||||
if totp.Max != 3 || totp.Window != 30*time.Second {
|
||||
t.Fatalf("unexpected totp rule: %+v", totp)
|
||||
}
|
||||
})
|
||||
}
|
||||
638
internal/config/loader.go
Normal file
638
internal/config/loader.go
Normal file
@@ -0,0 +1,638 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration unmarshals a Go duration string (e.g. "30s", "5m", "150ms") from JSON.
|
||||
// An empty string is treated as zero duration.
|
||||
type Duration struct{ time.Duration }
|
||||
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
d.Duration = 0
|
||||
return nil
|
||||
}
|
||||
dur, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
d.Duration = dur
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppConfig is the unified config loaded from a JSON file.
|
||||
type AppConfig struct {
|
||||
HTTPPort int
|
||||
MySQL MySQLConfig
|
||||
Logging LoggingConfig
|
||||
Audit AuditConfig
|
||||
Auth AuthConfig
|
||||
SES SESConfig
|
||||
Email EmailDeliveryConfig
|
||||
MicrosoftSSO MicrosoftSSOConfig
|
||||
HTTP HTTPConfig
|
||||
Resilience ResilienceConfig
|
||||
Queue QueueConfig
|
||||
FileQueue FileQueueConfig
|
||||
SQS SQSConfig
|
||||
FileSQS SQSConfig
|
||||
FileStorage FileStorageConfig
|
||||
FileUpload FileManagerUploadPolicyConfig
|
||||
SAML SAMLConfig
|
||||
WOPI WOPIConfig
|
||||
Gotenberg GotenbergConfig
|
||||
}
|
||||
|
||||
// LoadFromFile reads path, parses it as JSON, and returns a populated AppConfig.
|
||||
// The file is typically produced by scripts/inject-secrets.sh.
|
||||
func LoadFromFile(path string) (AppConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return AppConfig{}, fmt.Errorf("config: read %q: %w", path, err)
|
||||
}
|
||||
var raw jsonAppConfig
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return AppConfig{}, fmt.Errorf("config: parse %q: %w", path, err)
|
||||
}
|
||||
return raw.toAppConfig(), nil
|
||||
}
|
||||
|
||||
// ── JSON intermediate types ───────────────────────────────────────────────────
|
||||
|
||||
type jsonCircuitBreakerPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxRequests uint32 `json:"maxRequests"`
|
||||
Interval Duration `json:"interval"`
|
||||
BucketPeriod Duration `json:"bucketPeriod"`
|
||||
Timeout Duration `json:"timeout"`
|
||||
MinRequests uint32 `json:"minRequests"`
|
||||
FailureRatio float64 `json:"failureRatio"`
|
||||
ConsecutiveFailures uint32 `json:"consecutiveFailures"`
|
||||
}
|
||||
|
||||
func (j jsonCircuitBreakerPolicy) toPolicy() CircuitBreakerPolicy {
|
||||
return CircuitBreakerPolicy{
|
||||
Enabled: j.Enabled,
|
||||
MaxRequests: j.MaxRequests,
|
||||
Interval: j.Interval.Duration,
|
||||
BucketPeriod: j.BucketPeriod.Duration,
|
||||
Timeout: j.Timeout.Duration,
|
||||
MinRequests: j.MinRequests,
|
||||
FailureRatio: j.FailureRatio,
|
||||
ConsecutiveFailures: j.ConsecutiveFailures,
|
||||
}
|
||||
}
|
||||
|
||||
type jsonWorker struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
MaxBatchSize int `json:"maxBatchSize"`
|
||||
ProcessTimeout Duration `json:"processTimeout"`
|
||||
ShutdownTimeout Duration `json:"shutdownTimeout"`
|
||||
BackoffMin Duration `json:"backoffMin"`
|
||||
BackoffMax Duration `json:"backoffMax"`
|
||||
PermanentFailureDelay Duration `json:"permanentFailureDelay"`
|
||||
MonitorAddr string `json:"monitorAddr"`
|
||||
HealthErrorThreshold int `json:"healthErrorThreshold"`
|
||||
DepthPollInterval Duration `json:"depthPollInterval"`
|
||||
}
|
||||
|
||||
type jsonOutbox struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PollInterval Duration `json:"pollInterval"`
|
||||
BatchSize int `json:"batchSize"`
|
||||
DispatchTimeout Duration `json:"dispatchTimeout"`
|
||||
LockTTL Duration `json:"lockTTL"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
}
|
||||
|
||||
type jsonSQS struct {
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
|
||||
QueueURL string `json:"queueUrl"`
|
||||
QueueType string `json:"queueType"`
|
||||
MessageGroupID string `json:"messageGroupId"`
|
||||
MaxMessages int `json:"maxMessages"`
|
||||
WaitTimeSeconds int32 `json:"waitTimeSeconds"`
|
||||
VisibilityTimeout Duration `json:"visibilityTimeout"`
|
||||
}
|
||||
|
||||
func (j jsonSQS) toSQSConfig(regionFallback, endpointFallback, accessKeyID, secretAccessKey string) SQSConfig {
|
||||
region := j.Region
|
||||
if region == "" {
|
||||
region = regionFallback
|
||||
}
|
||||
endpoint := j.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = endpointFallback
|
||||
}
|
||||
return SQSConfig{
|
||||
Region: region,
|
||||
AccessKeyID: strings.TrimSpace(accessKeyID),
|
||||
SecretAccessKey: strings.TrimSpace(secretAccessKey),
|
||||
Endpoint: endpoint,
|
||||
QueueURL: j.QueueURL,
|
||||
QueueType: j.QueueType,
|
||||
MessageGroupID: j.MessageGroupID,
|
||||
MaxMessages: j.MaxMessages,
|
||||
WaitTimeSeconds: j.WaitTimeSeconds,
|
||||
VisibilityTimeout: j.VisibilityTimeout.Duration,
|
||||
AllowInsecureEndpoint: j.AllowInsecureEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
type jsonAppConfig struct {
|
||||
HTTP struct {
|
||||
Port int `json:"port"`
|
||||
BodyLimit int `json:"bodyLimit"`
|
||||
Timeouts struct {
|
||||
Read Duration `json:"read"`
|
||||
Write Duration `json:"write"`
|
||||
Idle Duration `json:"idle"`
|
||||
Request Duration `json:"request"`
|
||||
RequestOverrides string `json:"requestOverrides"`
|
||||
Shutdown Duration `json:"shutdown"`
|
||||
} `json:"timeouts"`
|
||||
CORS struct {
|
||||
AllowOrigins string `json:"allowOrigins"`
|
||||
AllowMethods string `json:"allowMethods"`
|
||||
AllowHeaders string `json:"allowHeaders"`
|
||||
ExposeHeaders string `json:"exposeHeaders"`
|
||||
AllowCredentials bool `json:"allowCredentials"`
|
||||
MaxAge int `json:"maxAge"`
|
||||
} `json:"cors"`
|
||||
RateLimit struct {
|
||||
Max int `json:"max"`
|
||||
Window Duration `json:"window"`
|
||||
Endpoints string `json:"endpoints"`
|
||||
} `json:"rateLimit"`
|
||||
Security struct {
|
||||
HSTSMaxAge int `json:"hstsMaxAge"`
|
||||
} `json:"security"`
|
||||
} `json:"http"`
|
||||
|
||||
Database struct {
|
||||
MySQL struct {
|
||||
DSN string `json:"dsn"`
|
||||
MaxOpenConns int `json:"maxOpenConns"`
|
||||
MaxIdleConns int `json:"maxIdleConns"`
|
||||
ConnMaxLifetime Duration `json:"connMaxLifetime"`
|
||||
ConnMaxIdleTime Duration `json:"connMaxIdleTime"`
|
||||
} `json:"mysql"`
|
||||
} `json:"database"`
|
||||
|
||||
Logging struct {
|
||||
Level string `json:"level"`
|
||||
Format string `json:"format"`
|
||||
} `json:"logging"`
|
||||
|
||||
Audit struct {
|
||||
QueueSize int `json:"queueSize"`
|
||||
Workers int `json:"workers"`
|
||||
} `json:"audit"`
|
||||
|
||||
Email struct {
|
||||
Provider string `json:"provider"`
|
||||
MockDir string `json:"mockDir"`
|
||||
} `json:"email"`
|
||||
|
||||
CircuitBreaker struct {
|
||||
Default jsonCircuitBreakerPolicy `json:"default"`
|
||||
MicrosoftSSO jsonCircuitBreakerPolicy `json:"microsoftSSO"`
|
||||
SES jsonCircuitBreakerPolicy `json:"ses"`
|
||||
SQS jsonCircuitBreakerPolicy `json:"sqs"`
|
||||
} `json:"circuitBreaker"`
|
||||
|
||||
Auth struct {
|
||||
PublicBaseURL string `json:"publicBaseURL"`
|
||||
PasswordPepper string `json:"passwordPepper"`
|
||||
IPEncryptionKey string `json:"ipEncryptionKey"`
|
||||
DefaultRole string `json:"defaultRole"`
|
||||
DisableRegister bool `json:"disableRegister"`
|
||||
Hash struct {
|
||||
Time uint32 `json:"time"`
|
||||
MemoryKB uint32 `json:"memoryKB"`
|
||||
Threads uint8 `json:"threads"`
|
||||
KeyLen uint32 `json:"keyLen"`
|
||||
} `json:"hash"`
|
||||
Email struct {
|
||||
VerifyTTL Duration `json:"verifyTTL"`
|
||||
VerifyURL string `json:"verifyURL"`
|
||||
InviteTTL Duration `json:"inviteTTL"`
|
||||
} `json:"email"`
|
||||
Password struct {
|
||||
SetURL string `json:"setURL"`
|
||||
ResetTTL Duration `json:"resetTTL"`
|
||||
ResetURL string `json:"resetURL"`
|
||||
ForgotEmailCooldown Duration `json:"forgotEmailCooldown"`
|
||||
ForgotMinDuration Duration `json:"forgotMinDuration"`
|
||||
ForgotIPRateMax int `json:"forgotIPRateMax"`
|
||||
ForgotIPRateWindow Duration `json:"forgotIPRateWindow"`
|
||||
LoginIPRateMax int `json:"loginIPRateMax"`
|
||||
LoginIPRateWindow Duration `json:"loginIPRateWindow"`
|
||||
} `json:"password"`
|
||||
SecurityPin struct {
|
||||
ResetTTL Duration `json:"resetTTL"`
|
||||
ResetURL string `json:"resetURL"`
|
||||
ForgotEmailCooldown Duration `json:"forgotEmailCooldown"`
|
||||
ForgotMinDuration Duration `json:"forgotMinDuration"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
LockDuration Duration `json:"lockDuration"`
|
||||
ActionTokenTTL Duration `json:"actionTokenTTL"`
|
||||
} `json:"securityPin"`
|
||||
JWT struct {
|
||||
AccessSecret string `json:"accessSecret"`
|
||||
RefreshSecret string `json:"refreshSecret"`
|
||||
AccessTTL Duration `json:"accessTTL"`
|
||||
RefreshTTL Duration `json:"refreshTTL"`
|
||||
RefreshTotpTTL Duration `json:"refreshTotpTTL"`
|
||||
LocalRefreshTTL Duration `json:"localRefreshTTL"`
|
||||
SessionIdleTTL Duration `json:"sessionIdleTTL"`
|
||||
Cookie struct {
|
||||
Domain string `json:"domain"`
|
||||
Secure bool `json:"secure"`
|
||||
SameSite string `json:"sameSite"`
|
||||
AccessName string `json:"accessName"`
|
||||
RefreshName string `json:"refreshName"`
|
||||
} `json:"cookie"`
|
||||
} `json:"jwt"`
|
||||
TOTP struct {
|
||||
ChallengeTTL Duration `json:"challengeTTL"`
|
||||
} `json:"totp"`
|
||||
SSO struct {
|
||||
StateTTL Duration `json:"stateTTL"`
|
||||
SuccessRedirect string `json:"successRedirect"`
|
||||
} `json:"sso"`
|
||||
WebAuthn struct {
|
||||
RPID string `json:"rpId"`
|
||||
RPDisplayName string `json:"rpDisplayName"`
|
||||
RPOrigins string `json:"rpOrigins"`
|
||||
ChallengeTTL Duration `json:"challengeTTL"`
|
||||
} `json:"webauthn"`
|
||||
} `json:"auth"`
|
||||
|
||||
TOTP struct {
|
||||
Issuer string `json:"issuer"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
} `json:"totp"`
|
||||
|
||||
MicrosoftSSO struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Authority string `json:"authority"`
|
||||
Scopes string `json:"scopes"`
|
||||
} `json:"microsoftSSO"`
|
||||
SAML struct {
|
||||
CertFile string `json:"certFile"`
|
||||
KeyFile string `json:"keyFile"`
|
||||
MetadataFile string `json:"metadataFile"`
|
||||
RootURL string `json:"rootUrl"`
|
||||
FrontendURL string `json:"frontendUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
CookieSecure bool `json:"cookieSecure"`
|
||||
SessionTTLHr int `json:"sessionTTLHour"`
|
||||
} `json:"saml"`
|
||||
|
||||
Queue struct {
|
||||
MessageSchemaVersion string `json:"messageSchemaVersion"`
|
||||
EnableLegacyPayloadFallback bool `json:"enableLegacyPayloadFallback"`
|
||||
IdempotencyKeyPrefix string `json:"idempotencyKeyPrefix"`
|
||||
IdempotencyTTL Duration `json:"idempotencyTTL"`
|
||||
ProcessingTTL Duration `json:"processingTTL"`
|
||||
Worker jsonWorker `json:"worker"`
|
||||
Outbox jsonOutbox `json:"outbox"`
|
||||
} `json:"queue"`
|
||||
|
||||
FileQueue struct {
|
||||
MessageSchemaVersion string `json:"messageSchemaVersion"`
|
||||
IdempotencyKeyPrefix string `json:"idempotencyKeyPrefix"`
|
||||
IdempotencyTTL Duration `json:"idempotencyTTL"`
|
||||
ProcessingTTL Duration `json:"processingTTL"`
|
||||
Worker jsonWorker `json:"worker"`
|
||||
Outbox jsonOutbox `json:"outbox"`
|
||||
} `json:"fileQueue"`
|
||||
|
||||
AWS struct {
|
||||
Region string `json:"region"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
SQS jsonSQS `json:"sqs"`
|
||||
FileSQS jsonSQS `json:"fileSqs"`
|
||||
SES struct {
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
From string `json:"from"`
|
||||
ConfigurationSet string `json:"configurationSet"`
|
||||
SendTimeout Duration `json:"sendTimeout"`
|
||||
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
|
||||
} `json:"ses"`
|
||||
} `json:"aws"`
|
||||
|
||||
File struct {
|
||||
Provider string `json:"provider"`
|
||||
S3 struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ForcePathStyle bool `json:"forcePathStyle"`
|
||||
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
|
||||
AutoCreateBucket bool `json:"autoCreateBucket"`
|
||||
ObjectKeyPrefix string `json:"objectKeyPrefix"`
|
||||
PresignPutTTL Duration `json:"presignPutTTL"`
|
||||
PresignGetTTL Duration `json:"presignGetTTL"`
|
||||
} `json:"s3"`
|
||||
Upload struct {
|
||||
MaxSize int64 `json:"maxSize"`
|
||||
AllowedMimeTypes string `json:"allowedMimeTypes"`
|
||||
AllowedExtensions string `json:"allowedExtensions"`
|
||||
AllowEmptyFile bool `json:"allowEmptyFile"`
|
||||
MaxFilenameLength int `json:"maxFilenameLength"`
|
||||
} `json:"upload"`
|
||||
} `json:"file"`
|
||||
|
||||
WOPI struct {
|
||||
TokenSecret string `json:"tokenSecret"`
|
||||
ProofSecret string `json:"proofSecret"`
|
||||
DiscoveryURL string `json:"discoveryUrl"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
EditActionURL string `json:"editActionUrl"`
|
||||
TokenTTL Duration `json:"tokenTTL"`
|
||||
MaxBodyBytes int `json:"maxBodyBytes"`
|
||||
RequireProof bool `json:"requireProof"`
|
||||
} `json:"wopi"`
|
||||
|
||||
Gotenberg struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"gotenberg"`
|
||||
}
|
||||
|
||||
func (r *jsonAppConfig) toAppConfig() AppConfig {
|
||||
// IPEncryptionKey falls back to PasswordPepper when not set (mirrors env loader behaviour)
|
||||
ipKey := r.Auth.IPEncryptionKey
|
||||
if ipKey == "" {
|
||||
ipKey = r.Auth.PasswordPepper
|
||||
}
|
||||
|
||||
// RefreshTotpTTL falls back to RefreshTTL when not set
|
||||
refreshTotpTTL := r.Auth.JWT.RefreshTotpTTL.Duration
|
||||
if refreshTotpTTL == 0 {
|
||||
refreshTotpTTL = r.Auth.JWT.RefreshTTL.Duration
|
||||
}
|
||||
|
||||
// SES region falls back to aws.region
|
||||
sesRegion := r.AWS.SES.Region
|
||||
if sesRegion == "" {
|
||||
sesRegion = r.AWS.Region
|
||||
}
|
||||
|
||||
return AppConfig{
|
||||
HTTPPort: r.HTTP.Port,
|
||||
|
||||
MySQL: MySQLConfig{
|
||||
DSN: r.Database.MySQL.DSN,
|
||||
MaxOpenConns: r.Database.MySQL.MaxOpenConns,
|
||||
MaxIdleConns: r.Database.MySQL.MaxIdleConns,
|
||||
ConnMaxLifetime: r.Database.MySQL.ConnMaxLifetime.Duration,
|
||||
ConnMaxIdleTime: r.Database.MySQL.ConnMaxIdleTime.Duration,
|
||||
},
|
||||
|
||||
Logging: LoggingConfig{
|
||||
Level: r.Logging.Level,
|
||||
Format: r.Logging.Format,
|
||||
},
|
||||
|
||||
Audit: AuditConfig{
|
||||
QueueSize: r.Audit.QueueSize,
|
||||
Workers: r.Audit.Workers,
|
||||
},
|
||||
|
||||
Email: EmailDeliveryConfig{
|
||||
Provider: strings.ToLower(strings.TrimSpace(r.Email.Provider)),
|
||||
MockDir: strings.TrimSpace(r.Email.MockDir),
|
||||
},
|
||||
|
||||
SES: SESConfig{
|
||||
Region: sesRegion,
|
||||
Endpoint: r.AWS.SES.Endpoint,
|
||||
From: r.AWS.SES.From,
|
||||
ConfigurationSetName: r.AWS.SES.ConfigurationSet,
|
||||
SendTimeout: r.AWS.SES.SendTimeout.Duration,
|
||||
AllowInsecureEndpoint: r.AWS.SES.AllowInsecureEndpoint,
|
||||
},
|
||||
|
||||
Auth: AuthConfig{
|
||||
PublicBaseURL: r.Auth.PublicBaseURL,
|
||||
PasswordPepper: r.Auth.PasswordPepper,
|
||||
IPEncryptionKey: ipKey,
|
||||
HashTime: r.Auth.Hash.Time,
|
||||
HashMemoryKB: r.Auth.Hash.MemoryKB,
|
||||
HashThreads: r.Auth.Hash.Threads,
|
||||
HashKeyLen: r.Auth.Hash.KeyLen,
|
||||
EmailVerifyTTL: r.Auth.Email.VerifyTTL.Duration,
|
||||
EmailVerifyURL: r.Auth.Email.VerifyURL,
|
||||
InviteTTL: r.Auth.Email.InviteTTL.Duration,
|
||||
SetPasswordURL: r.Auth.Password.SetURL,
|
||||
PasswordResetTTL: r.Auth.Password.ResetTTL.Duration,
|
||||
PasswordResetURL: r.Auth.Password.ResetURL,
|
||||
SecurityPINResetTTL: r.Auth.SecurityPin.ResetTTL.Duration,
|
||||
SecurityPINResetURL: r.Auth.SecurityPin.ResetURL,
|
||||
ForgotSecurityPINCooldown: r.Auth.SecurityPin.ForgotEmailCooldown.Duration,
|
||||
ForgotSecurityPINMinDuration: r.Auth.SecurityPin.ForgotMinDuration.Duration,
|
||||
SecurityPINMaxAttempts: r.Auth.SecurityPin.MaxAttempts,
|
||||
SecurityPINLockDuration: r.Auth.SecurityPin.LockDuration.Duration,
|
||||
SecurityPINActionTokenTTL: r.Auth.SecurityPin.ActionTokenTTL.Duration,
|
||||
ForgotPasswordEmailCooldown: r.Auth.Password.ForgotEmailCooldown.Duration,
|
||||
ForgotPasswordMinDuration: r.Auth.Password.ForgotMinDuration.Duration,
|
||||
ForgotPasswordIPRateMax: r.Auth.Password.ForgotIPRateMax,
|
||||
ForgotPasswordIPRateWindow: r.Auth.Password.ForgotIPRateWindow.Duration,
|
||||
LoginIPRateMax: r.Auth.Password.LoginIPRateMax,
|
||||
LoginIPRateWindow: r.Auth.Password.LoginIPRateWindow.Duration,
|
||||
TOTPChallengeTTL: r.Auth.TOTP.ChallengeTTL.Duration,
|
||||
SSOStateTTL: r.Auth.SSO.StateTTL.Duration,
|
||||
TOTPIssuer: r.TOTP.Issuer,
|
||||
TOTPSecretKeyB64: r.TOTP.SecretKey,
|
||||
JWTAccessSecret: r.Auth.JWT.AccessSecret,
|
||||
JWTRefreshSecret: r.Auth.JWT.RefreshSecret,
|
||||
JWTAccessTTL: r.Auth.JWT.AccessTTL.Duration,
|
||||
JWTRefreshTTL: r.Auth.JWT.RefreshTTL.Duration,
|
||||
JWTRefreshTOTPTTL: refreshTotpTTL,
|
||||
JWTLocalRefreshTTL: r.Auth.JWT.LocalRefreshTTL.Duration,
|
||||
SessionIdleTTL: r.Auth.JWT.SessionIdleTTL.Duration,
|
||||
JWTCookieDomain: r.Auth.JWT.Cookie.Domain,
|
||||
JWTCookieSecure: r.Auth.JWT.Cookie.Secure,
|
||||
JWTCookieSameSite: r.Auth.JWT.Cookie.SameSite,
|
||||
JWTAccessCookieName: r.Auth.JWT.Cookie.AccessName,
|
||||
JWTRefreshCookieName: r.Auth.JWT.Cookie.RefreshName,
|
||||
WebAuthnRPID: r.Auth.WebAuthn.RPID,
|
||||
WebAuthnRPDisplayName: r.Auth.WebAuthn.RPDisplayName,
|
||||
WebAuthnRPOrigins: splitCSV(r.Auth.WebAuthn.RPOrigins),
|
||||
WebAuthnChallengeTTL: r.Auth.WebAuthn.ChallengeTTL.Duration,
|
||||
SSOSuccessRedirectURL: r.Auth.SSO.SuccessRedirect,
|
||||
DefaultRoleName: r.Auth.DefaultRole,
|
||||
DisableRegister: r.Auth.DisableRegister,
|
||||
},
|
||||
|
||||
MicrosoftSSO: MicrosoftSSOConfig{
|
||||
TenantID: r.MicrosoftSSO.TenantID,
|
||||
ClientID: r.MicrosoftSSO.ClientID,
|
||||
ClientSecret: r.MicrosoftSSO.ClientSecret,
|
||||
RedirectURL: r.MicrosoftSSO.RedirectURL,
|
||||
Authority: r.MicrosoftSSO.Authority,
|
||||
Scopes: splitCSV(r.MicrosoftSSO.Scopes),
|
||||
},
|
||||
SAML: SAMLConfig{
|
||||
CertFile: strings.TrimSpace(r.SAML.CertFile),
|
||||
KeyFile: strings.TrimSpace(r.SAML.KeyFile),
|
||||
MetadataFile: strings.TrimSpace(r.SAML.MetadataFile),
|
||||
RootURL: strings.TrimSpace(r.SAML.RootURL),
|
||||
FrontendURL: strings.TrimSpace(r.SAML.FrontendURL),
|
||||
CookieDomain: strings.TrimSpace(r.SAML.CookieDomain),
|
||||
CookieSecure: r.SAML.CookieSecure,
|
||||
SessionTTLHr: r.SAML.SessionTTLHr,
|
||||
},
|
||||
|
||||
HTTP: HTTPConfig{
|
||||
BodyLimit: r.HTTP.BodyLimit,
|
||||
ReadTimeout: r.HTTP.Timeouts.Read.Duration,
|
||||
WriteTimeout: r.HTTP.Timeouts.Write.Duration,
|
||||
IdleTimeout: r.HTTP.Timeouts.Idle.Duration,
|
||||
RequestTimeout: r.HTTP.Timeouts.Request.Duration,
|
||||
RequestTimeoutByPath: parseHTTPTimeoutOverrides(r.HTTP.Timeouts.RequestOverrides),
|
||||
ShutdownTimeout: r.HTTP.Timeouts.Shutdown.Duration,
|
||||
CORSAllowOrigins: r.HTTP.CORS.AllowOrigins,
|
||||
CORSAllowMethods: r.HTTP.CORS.AllowMethods,
|
||||
CORSAllowHeaders: r.HTTP.CORS.AllowHeaders,
|
||||
CORSExposeHeaders: r.HTTP.CORS.ExposeHeaders,
|
||||
CORSAllowCredentials: r.HTTP.CORS.AllowCredentials,
|
||||
CORSMaxAge: r.HTTP.CORS.MaxAge,
|
||||
RateLimitMax: r.HTTP.RateLimit.Max,
|
||||
RateLimitWindow: r.HTTP.RateLimit.Window.Duration,
|
||||
RateLimitByEndpoint: parseHTTPEndpointRateLimitOverrides(r.HTTP.RateLimit.Endpoints),
|
||||
HSTSMaxAge: r.HTTP.Security.HSTSMaxAge,
|
||||
},
|
||||
|
||||
Resilience: ResilienceConfig{
|
||||
Default: r.CircuitBreaker.Default.toPolicy(),
|
||||
MicrosoftSSO: r.CircuitBreaker.MicrosoftSSO.toPolicy(),
|
||||
SES: r.CircuitBreaker.SES.toPolicy(),
|
||||
SQS: r.CircuitBreaker.SQS.toPolicy(),
|
||||
},
|
||||
|
||||
Queue: QueueConfig{
|
||||
MessageSchemaVersion: r.Queue.MessageSchemaVersion,
|
||||
EnableLegacyPayloadFallback: r.Queue.EnableLegacyPayloadFallback,
|
||||
IdempotencyKeyPrefix: r.Queue.IdempotencyKeyPrefix,
|
||||
IdempotencyTTL: r.Queue.IdempotencyTTL.Duration,
|
||||
ProcessingTTL: r.Queue.ProcessingTTL.Duration,
|
||||
WorkerConcurrency: r.Queue.Worker.Concurrency,
|
||||
WorkerMaxBatchSize: r.Queue.Worker.MaxBatchSize,
|
||||
WorkerProcessTimeout: r.Queue.Worker.ProcessTimeout.Duration,
|
||||
WorkerShutdownTimeout: r.Queue.Worker.ShutdownTimeout.Duration,
|
||||
WorkerBackoffMin: r.Queue.Worker.BackoffMin.Duration,
|
||||
WorkerBackoffMax: r.Queue.Worker.BackoffMax.Duration,
|
||||
WorkerPermanentFailureDelay: r.Queue.Worker.PermanentFailureDelay.Duration,
|
||||
WorkerMonitorAddr: r.Queue.Worker.MonitorAddr,
|
||||
WorkerHealthErrorThreshold: r.Queue.Worker.HealthErrorThreshold,
|
||||
WorkerDepthPollInterval: r.Queue.Worker.DepthPollInterval.Duration,
|
||||
OutboxEnabled: r.Queue.Outbox.Enabled,
|
||||
OutboxPollInterval: r.Queue.Outbox.PollInterval.Duration,
|
||||
OutboxBatchSize: r.Queue.Outbox.BatchSize,
|
||||
OutboxDispatchTimeout: r.Queue.Outbox.DispatchTimeout.Duration,
|
||||
OutboxLockTTL: r.Queue.Outbox.LockTTL.Duration,
|
||||
OutboxMaxAttempts: r.Queue.Outbox.MaxAttempts,
|
||||
},
|
||||
|
||||
FileQueue: FileQueueConfig{
|
||||
MessageSchemaVersion: r.FileQueue.MessageSchemaVersion,
|
||||
IdempotencyKeyPrefix: r.FileQueue.IdempotencyKeyPrefix,
|
||||
IdempotencyTTL: r.FileQueue.IdempotencyTTL.Duration,
|
||||
ProcessingTTL: r.FileQueue.ProcessingTTL.Duration,
|
||||
WorkerConcurrency: r.FileQueue.Worker.Concurrency,
|
||||
WorkerMaxBatchSize: r.FileQueue.Worker.MaxBatchSize,
|
||||
WorkerProcessTimeout: r.FileQueue.Worker.ProcessTimeout.Duration,
|
||||
WorkerShutdownTimeout: r.FileQueue.Worker.ShutdownTimeout.Duration,
|
||||
WorkerBackoffMin: r.FileQueue.Worker.BackoffMin.Duration,
|
||||
WorkerBackoffMax: r.FileQueue.Worker.BackoffMax.Duration,
|
||||
WorkerPermanentFailureDelay: r.FileQueue.Worker.PermanentFailureDelay.Duration,
|
||||
WorkerMonitorAddr: r.FileQueue.Worker.MonitorAddr,
|
||||
WorkerHealthErrorThreshold: r.FileQueue.Worker.HealthErrorThreshold,
|
||||
WorkerDepthPollInterval: r.FileQueue.Worker.DepthPollInterval.Duration,
|
||||
OutboxEnabled: r.FileQueue.Outbox.Enabled,
|
||||
OutboxPollInterval: r.FileQueue.Outbox.PollInterval.Duration,
|
||||
OutboxBatchSize: r.FileQueue.Outbox.BatchSize,
|
||||
OutboxDispatchTimeout: r.FileQueue.Outbox.DispatchTimeout.Duration,
|
||||
OutboxLockTTL: r.FileQueue.Outbox.LockTTL.Duration,
|
||||
OutboxMaxAttempts: r.FileQueue.Outbox.MaxAttempts,
|
||||
},
|
||||
|
||||
// SQS email queue: region falls back to aws.region
|
||||
SQS: r.AWS.SQS.toSQSConfig(r.AWS.Region, "", r.AWS.AccessKeyID, r.AWS.SecretAccessKey),
|
||||
|
||||
// FileSQS: region + endpoint fall back to aws.region / aws.sqs.endpoint
|
||||
FileSQS: r.AWS.FileSQS.toSQSConfig(r.AWS.Region, r.AWS.SQS.Endpoint, r.AWS.AccessKeyID, r.AWS.SecretAccessKey),
|
||||
|
||||
FileStorage: FileStorageConfig{
|
||||
Provider: strings.ToLower(strings.TrimSpace(r.File.Provider)),
|
||||
Bucket: strings.TrimSpace(r.File.S3.Bucket),
|
||||
Region: strings.TrimSpace(r.File.S3.Region),
|
||||
Endpoint: strings.TrimSpace(r.File.S3.Endpoint),
|
||||
AccessKeyID: strings.TrimSpace(r.AWS.AccessKeyID),
|
||||
SecretAccessKey: strings.TrimSpace(r.AWS.SecretAccessKey),
|
||||
ForcePathStyle: r.File.S3.ForcePathStyle,
|
||||
AllowInsecureEndpoint: r.File.S3.AllowInsecureEndpoint,
|
||||
AutoCreateBucket: r.File.S3.AutoCreateBucket,
|
||||
ObjectKeyPrefix: strings.TrimSpace(r.File.S3.ObjectKeyPrefix),
|
||||
PresignPutTTL: r.File.S3.PresignPutTTL.Duration,
|
||||
PresignGetTTL: r.File.S3.PresignGetTTL.Duration,
|
||||
},
|
||||
|
||||
FileUpload: FileManagerUploadPolicyConfig{
|
||||
MaxFileSizeBytes: r.File.Upload.MaxSize,
|
||||
AllowedMimeTypes: normalizeMimeTypes(splitCSV(r.File.Upload.AllowedMimeTypes)),
|
||||
AllowedExtensions: normalizeExtensions(splitCSV(r.File.Upload.AllowedExtensions)),
|
||||
AllowEmptyFile: r.File.Upload.AllowEmptyFile,
|
||||
MaxFilenameLength: r.File.Upload.MaxFilenameLength,
|
||||
},
|
||||
|
||||
WOPI: WOPIConfig{
|
||||
TokenSecret: strings.TrimSpace(r.WOPI.TokenSecret),
|
||||
ProofSecret: strings.TrimSpace(r.WOPI.ProofSecret),
|
||||
DiscoveryURL: strings.TrimSpace(r.WOPI.DiscoveryURL),
|
||||
PublicBaseURL: strings.TrimSpace(r.WOPI.PublicBaseURL),
|
||||
EditActionURL: strings.TrimSpace(r.WOPI.EditActionURL),
|
||||
TokenTTL: r.WOPI.TokenTTL.Duration,
|
||||
MaxBodyBytes: r.WOPI.MaxBodyBytes,
|
||||
RequireProof: r.WOPI.RequireProof,
|
||||
},
|
||||
|
||||
Gotenberg: GotenbergConfig{
|
||||
URL: strings.TrimSpace(r.Gotenberg.URL),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
5
internal/domain/action_signoff/errors.go
Normal file
5
internal/domain/action_signoff/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package actionsignoff
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("action sign-off not found")
|
||||
49
internal/domain/action_signoff/model.go
Normal file
49
internal/domain/action_signoff/model.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package actionsignoff
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
flightdomain "wucher/internal/domain/flight"
|
||||
helicopter "wucher/internal/domain/helicopter"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
// ActionSignoff is a corrective-action sign-off for a helicopter. When linked to a
|
||||
// complaint (ComplaintID set) it records that the complaint's corrective action was
|
||||
// signed off; when unlinked (ComplaintID nil) it is a complaint-independent duty
|
||||
// sign-off (gating the EASA maintenance release even without a complaint). There is
|
||||
// one row per complaint, and one current unlinked row per helicopter.
|
||||
type ActionSignoff struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
|
||||
// FlightID scopes the standalone (complaint-independent) sign-off to a flight,
|
||||
// so it no longer leaks across takeovers of the same aircraft. Nullable; unset
|
||||
// for complaint-linked rows (those are keyed by complaint_id).
|
||||
FlightID []byte `gorm:"type:binary(16);index;column:flight_id"`
|
||||
// ComplaintID unique (NULLs distinct in MySQL) → one sign-off per complaint;
|
||||
// standalone rows (NULL) are unconstrained here and instead deduped per flight
|
||||
// by the functional unique index uidx_action_signoffs_standalone_flight.
|
||||
ComplaintID []byte `gorm:"type:binary(16);uniqueIndex:uidx_action_signoffs_complaint_id;column:complaint_id"`
|
||||
|
||||
SignedAt *time.Time `gorm:"type:datetime(3);column:signed_at"`
|
||||
SignedBy []byte `gorm:"type:binary(16);column:signed_by"`
|
||||
|
||||
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
|
||||
Flight *flightdomain.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
||||
|
||||
CreatedAt time.Time `gorm:"type:datetime(3);column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime(3);column:updated_at"`
|
||||
}
|
||||
|
||||
func (ActionSignoff) TableName() string { return "action_signoffs" }
|
||||
|
||||
func (a *ActionSignoff) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ActionSignoff) IsSigned() bool { return a != nil && a.SignedAt != nil }
|
||||
11
internal/domain/action_signoff/repository.go
Normal file
11
internal/domain/action_signoff/repository.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package actionsignoff
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
GetByFlight(ctx context.Context, flightID []byte) (*ActionSignoff, error)
|
||||
GetByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
|
||||
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*ActionSignoff, error)
|
||||
Create(ctx context.Context, row *ActionSignoff) error
|
||||
Update(ctx context.Context, row *ActionSignoff) error
|
||||
}
|
||||
16
internal/domain/action_signoff/service.go
Normal file
16
internal/domain/action_signoff/service.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package actionsignoff
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
GetByFlight(ctx context.Context, flightID []byte) (*ActionSignoff, error)
|
||||
GetByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
|
||||
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*ActionSignoff, error)
|
||||
Sign(ctx context.Context, flightID, helicopterID []byte, signer []byte) (*ActionSignoff, error)
|
||||
SignForComplaint(ctx context.Context, complaintID, flightID, helicopterID []byte, signer []byte) (*ActionSignoff, error)
|
||||
Unsign(ctx context.Context, flightID []byte, actor []byte) (*ActionSignoff, error)
|
||||
// UnsignByComplaint clears the signature on a complaint-linked sign-off (no-op when
|
||||
// there is none or it is already unsigned) — used when a closed complaint is reopened
|
||||
// and to toggle a complaint sign-off off. Returns nil row when none exists.
|
||||
UnsignByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
|
||||
}
|
||||
58
internal/domain/after_flight_inspection/model.go
Normal file
58
internal/domain/after_flight_inspection/model.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package afterflightinspection
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type AfterFlightInspection struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
FlightInspectionID []byte `gorm:"type:binary(16);not null;uniqueIndex;column:flight_inspection_id"`
|
||||
OilEngineNR1Checked *string `gorm:"type:varchar(255);null;column:oil_engine_nr1_checked"`
|
||||
OilEngineNR2Checked *string `gorm:"type:varchar(255);null;column:oil_engine_nr2_checked"`
|
||||
HydraulicLHChecked *string `gorm:"type:varchar(255);null;column:hydraulic_lh_checked"`
|
||||
HydraulicRHChecked *string `gorm:"type:varchar(255);null;column:hydraulic_rh_checked"`
|
||||
OilTransmissionMGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_mgb_checked"`
|
||||
OilTransmissionIGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_igb_checked"`
|
||||
OilTransmissionTGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_tgb_checked"`
|
||||
Note *string `gorm:"type:text;column:note"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (AfterFlightInspection) TableName() string { return "after_flight_inspections" }
|
||||
|
||||
func (a *AfterFlightInspection) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidFlightInspectionID = errors.New("flight_inspection_id is invalid")
|
||||
ErrFlightInspectionNotFound = errors.New("flight inspection not found")
|
||||
ErrCapabilitiesUnavailable = errors.New("helicopter capabilities could not be resolved")
|
||||
|
||||
ErrNR1Required = errors.New("oil_engine_nr1_checked is required for this helicopter")
|
||||
ErrNR1NotAvailable = errors.New("NR1 capability not available on this helicopter")
|
||||
ErrNR2Required = errors.New("oil_engine_nr2_checked is required for this helicopter")
|
||||
ErrNR2NotAvailable = errors.New("NR2 capability not available on this helicopter")
|
||||
ErrLHRequired = errors.New("hydraulic_lh_checked is required for this helicopter")
|
||||
ErrLHNotAvailable = errors.New("LH capability not available on this helicopter")
|
||||
ErrRHRequired = errors.New("hydraulic_rh_checked is required for this helicopter")
|
||||
ErrRHNotAvailable = errors.New("RH capability not available on this helicopter")
|
||||
ErrMGBRequired = errors.New("oil_transmission_mgb_checked is required for this helicopter")
|
||||
ErrMGBNotAvailable = errors.New("MGB capability not available on this helicopter")
|
||||
ErrIGBRequired = errors.New("oil_transmission_igb_checked is required for this helicopter")
|
||||
ErrIGBNotAvailable = errors.New("IGB capability not available on this helicopter")
|
||||
ErrTGBRequired = errors.New("oil_transmission_tgb_checked is required for this helicopter")
|
||||
ErrTGBNotAvailable = errors.New("TGB capability not available on this helicopter")
|
||||
)
|
||||
21
internal/domain/after_flight_inspection/repository.go
Normal file
21
internal/domain/after_flight_inspection/repository.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package afterflightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Upsert(ctx context.Context, row *AfterFlightInspection) error
|
||||
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*AfterFlightInspection, error)
|
||||
DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error
|
||||
FlightInspectionExists(ctx context.Context, flightInspectionID []byte) (bool, error)
|
||||
GetHelicopterCapabilities(ctx context.Context, flightInspectionID []byte) (*HelicopterCapabilities, error)
|
||||
}
|
||||
|
||||
type HelicopterCapabilities struct {
|
||||
NR1 bool
|
||||
NR2 bool
|
||||
LH bool
|
||||
RH bool
|
||||
MGB bool
|
||||
IGB bool
|
||||
TGB bool
|
||||
}
|
||||
20
internal/domain/after_flight_inspection/service.go
Normal file
20
internal/domain/after_flight_inspection/service.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package afterflightinspection
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
Upsert(ctx context.Context, flightInspectionID []byte, req *UpsertRequest) (*AfterFlightInspection, error)
|
||||
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*AfterFlightInspection, error)
|
||||
DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
OilEngineNR1Checked *string
|
||||
OilEngineNR2Checked *string
|
||||
HydraulicLHChecked *string
|
||||
HydraulicRHChecked *string
|
||||
OilTransmissionMGBChecked *string
|
||||
OilTransmissionIGBChecked *string
|
||||
OilTransmissionTGBChecked *string
|
||||
Note *string
|
||||
}
|
||||
230
internal/domain/air_rescue_checklist/const.go
Normal file
230
internal/domain/air_rescue_checklist/const.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
AirRescuerChecklistScopeCAT = "CAT"
|
||||
AirRescuerChecklistScopeTA = "TA"
|
||||
AirRescuerChecklistScopeMO = "MO"
|
||||
AirRescuerChecklistScopeDI = "DI"
|
||||
AirRescuerChecklistScopeMI = "MI"
|
||||
AirRescuerChecklistScopeDO = "DO"
|
||||
AirRescuerChecklistScopeFR = "FR"
|
||||
AirRescuerChecklistScopeSA = "SA"
|
||||
AirRescuerChecklistScopeSO = "SO"
|
||||
AirRescuerChecklistScopeM10 = "M10"
|
||||
AirRescuerChecklistScopeM15 = "M15"
|
||||
AirRescuerChecklistScopeM20 = "M20"
|
||||
AirRescuerChecklistScopeM25 = "M25"
|
||||
AirRescuerChecklistScopeDS = "DS"
|
||||
)
|
||||
|
||||
const (
|
||||
AirRescuerChecklistScopeOverview = AirRescuerChecklistScopeCAT
|
||||
AirRescuerChecklistScopeDaily = AirRescuerChecklistScopeTA
|
||||
AirRescuerChecklistScopeMonday = AirRescuerChecklistScopeMO
|
||||
AirRescuerChecklistScopeTuesday = AirRescuerChecklistScopeDI
|
||||
AirRescuerChecklistScopeWednesday = AirRescuerChecklistScopeMI
|
||||
AirRescuerChecklistScopeThursday = AirRescuerChecklistScopeDO
|
||||
AirRescuerChecklistScopeFriday = AirRescuerChecklistScopeFR
|
||||
AirRescuerChecklistScopeSaturday = AirRescuerChecklistScopeSA
|
||||
AirRescuerChecklistScopeSunday = AirRescuerChecklistScopeSO
|
||||
AirRescuerChecklistScopeDeadline = AirRescuerChecklistScopeDS
|
||||
)
|
||||
|
||||
var airRescuerChecklistPersistedScopeOrder = []string{
|
||||
AirRescuerChecklistScopeTA,
|
||||
AirRescuerChecklistScopeMO,
|
||||
AirRescuerChecklistScopeDI,
|
||||
AirRescuerChecklistScopeMI,
|
||||
AirRescuerChecklistScopeDO,
|
||||
AirRescuerChecklistScopeFR,
|
||||
AirRescuerChecklistScopeSA,
|
||||
AirRescuerChecklistScopeSO,
|
||||
AirRescuerChecklistScopeM10,
|
||||
AirRescuerChecklistScopeM15,
|
||||
AirRescuerChecklistScopeM20,
|
||||
AirRescuerChecklistScopeM25,
|
||||
AirRescuerChecklistScopeDS,
|
||||
}
|
||||
|
||||
var airRescuerChecklistPersistedScopeSet = map[string]struct{}{
|
||||
AirRescuerChecklistScopeTA: {},
|
||||
AirRescuerChecklistScopeMO: {},
|
||||
AirRescuerChecklistScopeDI: {},
|
||||
AirRescuerChecklistScopeMI: {},
|
||||
AirRescuerChecklistScopeDO: {},
|
||||
AirRescuerChecklistScopeFR: {},
|
||||
AirRescuerChecklistScopeSA: {},
|
||||
AirRescuerChecklistScopeSO: {},
|
||||
AirRescuerChecklistScopeM10: {},
|
||||
AirRescuerChecklistScopeM15: {},
|
||||
AirRescuerChecklistScopeM20: {},
|
||||
AirRescuerChecklistScopeM25: {},
|
||||
AirRescuerChecklistScopeDS: {},
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeDisplayNamesEN = map[string]string{
|
||||
AirRescuerChecklistScopeCAT: "Category",
|
||||
AirRescuerChecklistScopeTA: "Daily",
|
||||
AirRescuerChecklistScopeMO: "Monday",
|
||||
AirRescuerChecklistScopeDI: "Tuesday",
|
||||
AirRescuerChecklistScopeMI: "Wednesday",
|
||||
AirRescuerChecklistScopeDO: "Thursday",
|
||||
AirRescuerChecklistScopeFR: "Friday",
|
||||
AirRescuerChecklistScopeSA: "Saturday",
|
||||
AirRescuerChecklistScopeSO: "Sunday",
|
||||
AirRescuerChecklistScopeM10: "M10",
|
||||
AirRescuerChecklistScopeM15: "M15",
|
||||
AirRescuerChecklistScopeM20: "M20",
|
||||
AirRescuerChecklistScopeM25: "M25",
|
||||
AirRescuerChecklistScopeDS: "Deadline",
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeDisplayNamesDE = map[string]string{
|
||||
AirRescuerChecklistScopeCAT: "Category",
|
||||
AirRescuerChecklistScopeTA: "Taeglich",
|
||||
AirRescuerChecklistScopeMO: "Montag",
|
||||
AirRescuerChecklistScopeDI: "Dienstag",
|
||||
AirRescuerChecklistScopeMI: "Mittwoch",
|
||||
AirRescuerChecklistScopeDO: "Donnerstag",
|
||||
AirRescuerChecklistScopeFR: "Freitag",
|
||||
AirRescuerChecklistScopeSA: "Samstag",
|
||||
AirRescuerChecklistScopeSO: "Sonntag",
|
||||
AirRescuerChecklistScopeM10: "M10",
|
||||
AirRescuerChecklistScopeM15: "M15",
|
||||
AirRescuerChecklistScopeM20: "M20",
|
||||
AirRescuerChecklistScopeM25: "M25",
|
||||
AirRescuerChecklistScopeDS: "Deadline",
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeCodesEN = map[string]string{
|
||||
AirRescuerChecklistScopeCAT: "CAT",
|
||||
AirRescuerChecklistScopeTA: "DA",
|
||||
AirRescuerChecklistScopeMO: "MO",
|
||||
AirRescuerChecklistScopeDI: "TU",
|
||||
AirRescuerChecklistScopeMI: "WE",
|
||||
AirRescuerChecklistScopeDO: "TH",
|
||||
AirRescuerChecklistScopeFR: "FR",
|
||||
AirRescuerChecklistScopeSA: "SA",
|
||||
AirRescuerChecklistScopeSO: "SU",
|
||||
AirRescuerChecklistScopeM10: "M10",
|
||||
AirRescuerChecklistScopeM15: "M15",
|
||||
AirRescuerChecklistScopeM20: "M20",
|
||||
AirRescuerChecklistScopeM25: "M25",
|
||||
AirRescuerChecklistScopeDS: "DL",
|
||||
}
|
||||
|
||||
var airRescuerChecklistScopeAliases = map[string]string{
|
||||
"CAT": AirRescuerChecklistScopeCAT,
|
||||
"OVERVIEW": AirRescuerChecklistScopeCAT,
|
||||
|
||||
"TA": AirRescuerChecklistScopeTA,
|
||||
"DA": AirRescuerChecklistScopeTA,
|
||||
"DAY": AirRescuerChecklistScopeTA,
|
||||
"DAILY": AirRescuerChecklistScopeTA,
|
||||
|
||||
"MO": AirRescuerChecklistScopeMO,
|
||||
"MON": AirRescuerChecklistScopeMO,
|
||||
"MONDAY": AirRescuerChecklistScopeMO,
|
||||
|
||||
"DI": AirRescuerChecklistScopeDI,
|
||||
"TU": AirRescuerChecklistScopeDI,
|
||||
"TUE": AirRescuerChecklistScopeDI,
|
||||
"TUESDAY": AirRescuerChecklistScopeDI,
|
||||
|
||||
"MI": AirRescuerChecklistScopeMI,
|
||||
"WE": AirRescuerChecklistScopeMI,
|
||||
"WED": AirRescuerChecklistScopeMI,
|
||||
"WEDNESDAY": AirRescuerChecklistScopeMI,
|
||||
|
||||
"DO": AirRescuerChecklistScopeDO,
|
||||
"TH": AirRescuerChecklistScopeDO,
|
||||
"THU": AirRescuerChecklistScopeDO,
|
||||
"THURSDAY": AirRescuerChecklistScopeDO,
|
||||
|
||||
"FR": AirRescuerChecklistScopeFR,
|
||||
"FRI": AirRescuerChecklistScopeFR,
|
||||
"FRIDAY": AirRescuerChecklistScopeFR,
|
||||
|
||||
"SA": AirRescuerChecklistScopeSA,
|
||||
"SAT": AirRescuerChecklistScopeSA,
|
||||
"SATURDAY": AirRescuerChecklistScopeSA,
|
||||
|
||||
"SO": AirRescuerChecklistScopeSO,
|
||||
"SU": AirRescuerChecklistScopeSO,
|
||||
"SUN": AirRescuerChecklistScopeSO,
|
||||
"SUNDAY": AirRescuerChecklistScopeSO,
|
||||
|
||||
"M10": AirRescuerChecklistScopeM10,
|
||||
"M15": AirRescuerChecklistScopeM15,
|
||||
"M20": AirRescuerChecklistScopeM20,
|
||||
"M25": AirRescuerChecklistScopeM25,
|
||||
|
||||
"DS": AirRescuerChecklistScopeDS,
|
||||
"DL": AirRescuerChecklistScopeDS,
|
||||
"DEADLINE": AirRescuerChecklistScopeDS,
|
||||
}
|
||||
|
||||
func CanonicalizeAirRescuerChecklistScope(scopeCode string) string {
|
||||
return normalizeAirRescuerChecklistScope(scopeCode)
|
||||
}
|
||||
|
||||
func IsAirRescuerChecklistPersistedScope(scopeCode string) bool {
|
||||
_, ok := airRescuerChecklistPersistedScopeSet[normalizeAirRescuerChecklistScope(scopeCode)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsAirRescuerChecklistQueryScope(scopeCode string) bool {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if normalized == AirRescuerChecklistScopeCAT {
|
||||
return true
|
||||
}
|
||||
return IsAirRescuerChecklistPersistedScope(normalized)
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeDisplayName(scopeCode string) string {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if v, ok := airRescuerChecklistScopeDisplayNamesEN[normalized]; ok {
|
||||
return v
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeDisplayNameDE(scopeCode string) string {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if v, ok := airRescuerChecklistScopeDisplayNamesDE[normalized]; ok {
|
||||
return v
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeCodeEN(scopeCode string) string {
|
||||
normalized := normalizeAirRescuerChecklistScope(scopeCode)
|
||||
if v, ok := airRescuerChecklistScopeCodesEN[normalized]; ok {
|
||||
return v
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func AirRescuerChecklistScopeCodeDE(scopeCode string) string {
|
||||
return normalizeAirRescuerChecklistScope(scopeCode)
|
||||
}
|
||||
|
||||
func AirRescuerChecklistPersistedScopeOrder() []string {
|
||||
out := make([]string, 0, len(airRescuerChecklistPersistedScopeOrder))
|
||||
out = append(out, airRescuerChecklistPersistedScopeOrder...)
|
||||
return out
|
||||
}
|
||||
|
||||
func IsAirRescuerChecklistScope(scopeCode string) bool {
|
||||
return IsAirRescuerChecklistPersistedScope(scopeCode)
|
||||
}
|
||||
|
||||
func normalizeAirRescuerChecklistScope(scopeCode string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(scopeCode))
|
||||
if canonical, ok := airRescuerChecklistScopeAliases[normalized]; ok {
|
||||
return canonical
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
159
internal/domain/air_rescue_checklist/const_test.go
Normal file
159
internal/domain/air_rescue_checklist/const_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAirRescuerChecklistScopeHelpers(t *testing.T) {
|
||||
persistedScopes := []string{
|
||||
AirRescuerChecklistScopeTA,
|
||||
AirRescuerChecklistScopeMO,
|
||||
AirRescuerChecklistScopeDI,
|
||||
AirRescuerChecklistScopeMI,
|
||||
AirRescuerChecklistScopeDO,
|
||||
AirRescuerChecklistScopeFR,
|
||||
AirRescuerChecklistScopeSA,
|
||||
AirRescuerChecklistScopeSO,
|
||||
AirRescuerChecklistScopeM10,
|
||||
AirRescuerChecklistScopeM15,
|
||||
AirRescuerChecklistScopeM20,
|
||||
AirRescuerChecklistScopeM25,
|
||||
AirRescuerChecklistScopeDS,
|
||||
}
|
||||
|
||||
for _, scope := range persistedScopes {
|
||||
if !IsAirRescuerChecklistPersistedScope(scope) {
|
||||
t.Fatalf("expected persisted scope valid: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistPersistedScope(" " + scope + " ") {
|
||||
t.Fatalf("expected persisted scope valid with trim: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistPersistedScope(lower(scope)) {
|
||||
t.Fatalf("expected persisted scope valid with lowercase: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistQueryScope(scope) {
|
||||
t.Fatalf("expected query scope valid: %s", scope)
|
||||
}
|
||||
if !IsAirRescuerChecklistScope(scope) {
|
||||
t.Fatalf("expected compatibility scope valid: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
if IsAirRescuerChecklistPersistedScope(AirRescuerChecklistScopeCAT) {
|
||||
t.Fatalf("expected CAT not persisted")
|
||||
}
|
||||
if !IsAirRescuerChecklistQueryScope(AirRescuerChecklistScopeCAT) {
|
||||
t.Fatalf("expected CAT valid for query")
|
||||
}
|
||||
if IsAirRescuerChecklistQueryScope("UNKNOWN") {
|
||||
t.Fatalf("expected unknown query scope invalid")
|
||||
}
|
||||
if IsAirRescuerChecklistScope("UNKNOWN") {
|
||||
t.Fatalf("expected unknown compatibility scope invalid")
|
||||
}
|
||||
|
||||
englishAliases := map[string]string{
|
||||
"daily": AirRescuerChecklistScopeTA,
|
||||
"tuesday": AirRescuerChecklistScopeDI,
|
||||
"wednesday": AirRescuerChecklistScopeMI,
|
||||
"thursday": AirRescuerChecklistScopeDO,
|
||||
"sunday": AirRescuerChecklistScopeSO,
|
||||
"deadline": AirRescuerChecklistScopeDS,
|
||||
"overview": AirRescuerChecklistScopeCAT,
|
||||
"tu": AirRescuerChecklistScopeDI,
|
||||
"we": AirRescuerChecklistScopeMI,
|
||||
"th": AirRescuerChecklistScopeDO,
|
||||
"su": AirRescuerChecklistScopeSO,
|
||||
}
|
||||
|
||||
for alias, expected := range englishAliases {
|
||||
if got := CanonicalizeAirRescuerChecklistScope(alias); got != expected {
|
||||
t.Fatalf("expected alias %s canonicalized to %s, got %s", alias, expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsAirRescuerChecklistPersistedScope("tu") {
|
||||
t.Fatalf("expected TU alias valid as persisted scope")
|
||||
}
|
||||
if !IsAirRescuerChecklistQueryScope("overview") {
|
||||
t.Fatalf("expected OVERVIEW alias valid as query scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistScopeDisplayName(t *testing.T) {
|
||||
if got := AirRescuerChecklistScopeDisplayName(AirRescuerChecklistScopeCAT); got != "Category" {
|
||||
t.Fatalf("unexpected CAT display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayName(" mo "); got != "Monday" {
|
||||
t.Fatalf("unexpected MO display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayName("x"); got != "X" {
|
||||
t.Fatalf("unexpected fallback display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayName("tu"); got != "Tuesday" {
|
||||
t.Fatalf("unexpected TU display name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistScopeDisplayNameDE(t *testing.T) {
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE(AirRescuerChecklistScopeCAT); got != "Category" {
|
||||
t.Fatalf("unexpected CAT DE display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE(" mo "); got != "Montag" {
|
||||
t.Fatalf("unexpected MO DE display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE("x"); got != "X" {
|
||||
t.Fatalf("unexpected DE fallback display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE("thursday"); got != "Donnerstag" {
|
||||
t.Fatalf("unexpected THURSDAY DE display name: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeDisplayNameDE(AirRescuerChecklistScopeDS); got != "Deadline" {
|
||||
t.Fatalf("unexpected DS DE display name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistScopeCodes(t *testing.T) {
|
||||
if got := AirRescuerChecklistScopeCodeEN(AirRescuerChecklistScopeTA); got != "DA" {
|
||||
t.Fatalf("unexpected TA EN code: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeCodeEN("thursday"); got != "TH" {
|
||||
t.Fatalf("unexpected THURSDAY EN code: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeCodeEN("x"); got != "X" {
|
||||
t.Fatalf("unexpected EN fallback code: %s", got)
|
||||
}
|
||||
|
||||
if got := AirRescuerChecklistScopeCodeDE(AirRescuerChecklistScopeDI); got != AirRescuerChecklistScopeDI {
|
||||
t.Fatalf("unexpected DI DE code: %s", got)
|
||||
}
|
||||
if got := AirRescuerChecklistScopeCodeDE("tu"); got != AirRescuerChecklistScopeDI {
|
||||
t.Fatalf("unexpected TU DE code: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistPersistedScopeOrderReturnsCopy(t *testing.T) {
|
||||
first := AirRescuerChecklistPersistedScopeOrder()
|
||||
if len(first) != 13 {
|
||||
t.Fatalf("unexpected persisted scope order size: %d", len(first))
|
||||
}
|
||||
if first[0] != AirRescuerChecklistScopeTA || first[len(first)-1] != AirRescuerChecklistScopeDS {
|
||||
t.Fatalf("unexpected persisted scope order: %+v", first)
|
||||
}
|
||||
|
||||
first[0] = "BROKEN"
|
||||
second := AirRescuerChecklistPersistedScopeOrder()
|
||||
if second[0] != AirRescuerChecklistScopeTA {
|
||||
t.Fatalf("expected second call to return independent copy, got: %s", second[0])
|
||||
}
|
||||
}
|
||||
|
||||
func lower(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
ch = ch + ('a' - 'A')
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
117
internal/domain/air_rescue_checklist/model.go
Normal file
117
internal/domain/air_rescue_checklist/model.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type AirRescuerChecklist struct {
|
||||
ID []byte `gorm:"column:id;type:binary(16);primaryKey"`
|
||||
HEMSBaseID []byte `gorm:"column:hems_base_id;type:binary(16);not null"`
|
||||
ScopeCode string `gorm:"column:scope_code;type:varchar(20);not null"`
|
||||
Title string `gorm:"column:title;type:varchar(150);not null"`
|
||||
Position int `gorm:"column:position;not null;default:1"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
CreatedBy []byte `gorm:"column:created_by;type:binary(16)"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
UpdatedBy []byte `gorm:"column:updated_by;type:binary(16)"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"column:deleted_by;type:binary(16)"`
|
||||
}
|
||||
|
||||
func (AirRescuerChecklist) TableName() string {
|
||||
return "air_rescuer_checklists"
|
||||
}
|
||||
|
||||
func (h *AirRescuerChecklist) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(h.ID) == 0 {
|
||||
h.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AirRescuerChecklistItem struct {
|
||||
ID []byte `gorm:"column:id;type:binary(16);primaryKey"`
|
||||
ChecklistID []byte `gorm:"column:checklist_id;type:binary(16);not null"`
|
||||
Name string `gorm:"column:name;type:varchar(255);not null"`
|
||||
Position int `gorm:"column:position;not null;default:1"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
CreatedBy []byte `gorm:"column:created_by;type:binary(16)"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
UpdatedBy []byte `gorm:"column:updated_by;type:binary(16)"`
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at"`
|
||||
DeletedBy []byte `gorm:"column:deleted_by;type:binary(16)"`
|
||||
}
|
||||
|
||||
func (AirRescuerChecklistItem) TableName() string {
|
||||
return "air_rescuer_checklist_items"
|
||||
}
|
||||
|
||||
func (h *AirRescuerChecklistItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(h.ID) == 0 {
|
||||
h.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HEMSBaseView struct {
|
||||
ID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
BaseCategory string
|
||||
Position int
|
||||
}
|
||||
|
||||
type ChecklistHeaderView struct {
|
||||
ID []byte
|
||||
HEMSBaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
BaseCategory string
|
||||
ScopeCode string
|
||||
Title string
|
||||
Position int
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ChecklistItemView struct {
|
||||
ID []byte
|
||||
ChecklistID []byte
|
||||
Name string
|
||||
Position int
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ChecklistHeaderFilter struct {
|
||||
HEMSBaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
ScopeCode string
|
||||
CategoryType string
|
||||
}
|
||||
|
||||
type HEMSBaseFilter struct {
|
||||
HEMSBaseID []byte
|
||||
BaseName string
|
||||
BaseAbbreviation string
|
||||
CategoryType string
|
||||
}
|
||||
|
||||
type ChecklistCreateInput struct {
|
||||
Checklist AirRescuerChecklist
|
||||
Items []AirRescuerChecklistItem
|
||||
}
|
||||
|
||||
type ChecklistItemReorderInput struct {
|
||||
ID []byte
|
||||
Position int
|
||||
}
|
||||
52
internal/domain/air_rescue_checklist/model_test.go
Normal file
52
internal/domain/air_rescue_checklist/model_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAirRescuerChecklistModelTableNameAndBeforeCreate(t *testing.T) {
|
||||
var header AirRescuerChecklist
|
||||
if got := header.TableName(); got != "air_rescuer_checklists" {
|
||||
t.Fatalf("unexpected checklist table name: %s", got)
|
||||
}
|
||||
|
||||
if err := header.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist: %v", err)
|
||||
}
|
||||
if len(header.ID) == 0 {
|
||||
t.Fatalf("expected checklist id generated")
|
||||
}
|
||||
|
||||
existingID := []byte("1234567890123456")
|
||||
header2 := AirRescuerChecklist{ID: append([]byte(nil), existingID...)}
|
||||
if err := header2.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist with existing id: %v", err)
|
||||
}
|
||||
if !bytes.Equal(header2.ID, existingID) {
|
||||
t.Fatalf("expected existing checklist id preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAirRescuerChecklistItemModelTableNameAndBeforeCreate(t *testing.T) {
|
||||
var item AirRescuerChecklistItem
|
||||
if got := item.TableName(); got != "air_rescuer_checklist_items" {
|
||||
t.Fatalf("unexpected checklist item table name: %s", got)
|
||||
}
|
||||
|
||||
if err := item.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist item: %v", err)
|
||||
}
|
||||
if len(item.ID) == 0 {
|
||||
t.Fatalf("expected checklist item id generated")
|
||||
}
|
||||
|
||||
existingID := []byte("abcdefghijklmnop")
|
||||
item2 := AirRescuerChecklistItem{ID: append([]byte(nil), existingID...)}
|
||||
if err := item2.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("BeforeCreate checklist item with existing id: %v", err)
|
||||
}
|
||||
if !bytes.Equal(item2.ID, existingID) {
|
||||
t.Fatalf("expected existing checklist item id preserved")
|
||||
}
|
||||
}
|
||||
28
internal/domain/air_rescue_checklist/repository.go
Normal file
28
internal/domain/air_rescue_checklist/repository.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "context"
|
||||
|
||||
type TxRepository interface {
|
||||
CreateChecklist(ctx context.Context, row *AirRescuerChecklist) error
|
||||
NextChecklistPosition(ctx context.Context, hemsBaseID []byte, scopeCode string) (int, error)
|
||||
LockChecklistByID(ctx context.Context, id []byte) (*AirRescuerChecklist, error)
|
||||
UpdateChecklist(ctx context.Context, row *AirRescuerChecklist) error
|
||||
SoftDeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
SoftDeleteItemsByChecklistID(ctx context.Context, checklistID []byte, deletedBy []byte) error
|
||||
|
||||
CreateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
CreateChecklistItems(ctx context.Context, rows []AirRescuerChecklistItem) error
|
||||
NextChecklistItemPosition(ctx context.Context, checklistID []byte) (int, error)
|
||||
LockChecklistItemByID(ctx context.Context, id []byte) (*AirRescuerChecklistItem, error)
|
||||
UpdateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
SoftDeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
InTx(ctx context.Context, fn func(txRepo TxRepository) error) error
|
||||
GetChecklistByID(ctx context.Context, id []byte) (*ChecklistHeaderView, error)
|
||||
GetChecklistItemByID(ctx context.Context, id []byte) (*ChecklistItemView, error)
|
||||
ListChecklistHeaders(ctx context.Context, filter ChecklistHeaderFilter) ([]ChecklistHeaderView, error)
|
||||
ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]ChecklistItemView, error)
|
||||
ListHEMSBases(ctx context.Context, filter HEMSBaseFilter) ([]HEMSBaseView, error)
|
||||
}
|
||||
21
internal/domain/air_rescue_checklist/service.go
Normal file
21
internal/domain/air_rescue_checklist/service.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package airrescuechecklist
|
||||
|
||||
import "context"
|
||||
|
||||
type Service interface {
|
||||
CreateBulk(ctx context.Context, rows []ChecklistCreateInput) error
|
||||
UpdateChecklist(ctx context.Context, row *AirRescuerChecklist) error
|
||||
UpdateChecklistWithNewItems(ctx context.Context, row *AirRescuerChecklist, newItems []AirRescuerChecklistItem) error
|
||||
DeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
CreateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
CreateChecklistItems(ctx context.Context, checklistID []byte, rows []AirRescuerChecklistItem) error
|
||||
ReorderChecklistItems(ctx context.Context, checklistID []byte, rows []ChecklistItemReorderInput, updatedBy []byte) error
|
||||
UpdateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
|
||||
DeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error
|
||||
|
||||
GetChecklistByID(ctx context.Context, id []byte) (*ChecklistHeaderView, error)
|
||||
GetChecklistItemByID(ctx context.Context, id []byte) (*ChecklistItemView, error)
|
||||
ListChecklistHeaders(ctx context.Context, filter ChecklistHeaderFilter) ([]ChecklistHeaderView, error)
|
||||
ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]ChecklistItemView, error)
|
||||
ListHEMSBases(ctx context.Context, filter HEMSBaseFilter) ([]HEMSBaseView, error)
|
||||
}
|
||||
36
internal/domain/audit/model.go
Normal file
36
internal/domain/audit/model.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
RequestID string `gorm:"type:varchar(64);index;column:request_id"`
|
||||
ActorUserID []byte `gorm:"type:binary(16);index;column:actor_user_id"`
|
||||
Layer string `gorm:"type:varchar(32);index;not null;column:layer"`
|
||||
Module string `gorm:"type:varchar(64);index;column:module"`
|
||||
Action string `gorm:"type:varchar(128);index;not null;column:action"`
|
||||
Method string `gorm:"type:varchar(12);index;column:method"`
|
||||
Path string `gorm:"type:varchar(255);index;column:path"`
|
||||
StatusCode int `gorm:"index;column:status_code"`
|
||||
Success bool `gorm:"type:tinyint(1);index;not null;default:0;column:success"`
|
||||
IP string `gorm:"type:varchar(128);column:ip"`
|
||||
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
|
||||
Message string `gorm:"type:varchar(512);column:message"`
|
||||
Metadata []byte `gorm:"type:json;column:metadata"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (AuditLog) TableName() string { return "audit_logs" }
|
||||
|
||||
func (a *AuditLog) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(a.ID) == 0 {
|
||||
a.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
internal/domain/audit/repository.go
Normal file
7
internal/domain/audit/repository.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, entry *AuditLog) error
|
||||
}
|
||||
16
internal/domain/auth/contact_constants.go
Normal file
16
internal/domain/auth/contact_constants.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
const (
|
||||
RoleCodePilot = "pilot"
|
||||
RoleCodeDoctor = "doctor"
|
||||
RoleCodeAirRescuer = "air_rescuer"
|
||||
RoleCodeTechnician = "technician"
|
||||
RoleCodeFlightAssistant = "flight_assistant"
|
||||
RoleCodeStaff = "staff"
|
||||
)
|
||||
|
||||
const (
|
||||
PilotCategoryRegular = "regular"
|
||||
PilotCategoryFreelance = "freelance"
|
||||
PilotCategoryDryLease = "dry_lease"
|
||||
)
|
||||
141
internal/domain/auth/contact_profiles.go
Normal file
141
internal/domain/auth/contact_profiles.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// PilotProfile stores role-specific data for role=pilot.
|
||||
type PilotProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
PilotCategory string `gorm:"type:varchar(16);not null;index;column:pilot_category"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
|
||||
LicenseIssuedAt *time.Time `gorm:"column:license_issued_at"`
|
||||
LicenseExpiredAt *time.Time `gorm:"column:license_expired_at"`
|
||||
TechnicianLicenseNo string `gorm:"type:varchar(120);column:technician_license_no"`
|
||||
HasIFRQualification bool `gorm:"not null;default:false;column:has_ifr_qualification"`
|
||||
IsChiefPilot bool `gorm:"not null;default:false;column:is_chief_pilot"`
|
||||
IsDUL bool `gorm:"not null;default:false;column:is_dul"`
|
||||
TotalFlightMinutes int `gorm:"not null;default:0;column:total_flight_minutes"`
|
||||
ResponsiblePilotMinutes int `gorm:"not null;default:0;column:responsible_pilot_minutes"`
|
||||
SecondPilotMinutes int `gorm:"not null;default:0;column:second_pilot_minutes"`
|
||||
DoubleCommandMinutes int `gorm:"not null;default:0;column:double_command_minutes"`
|
||||
FlightInstructorMinutes int `gorm:"not null;default:0;column:flight_instructor_minutes"`
|
||||
NightFlightMinutes int `gorm:"not null;default:0;column:night_flight_minutes"`
|
||||
IFRFlightMinutes int `gorm:"not null;default:0;column:ifr_flight_minutes"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
WeightKG *float64 `gorm:"type:decimal(8,2);column:weight_kg"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (PilotProfile) TableName() string { return "pilot_profiles" }
|
||||
|
||||
// DoctorProfile stores role-specific data for role=doctor.
|
||||
type DoctorProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
Specialization string `gorm:"type:varchar(120);column:specialization"`
|
||||
SubSpecialization string `gorm:"type:varchar(120);column:sub_specialization"`
|
||||
IsChiefPhysician bool `gorm:"not null;default:false;column:is_chief_physician"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (DoctorProfile) TableName() string { return "doctor_profiles" }
|
||||
|
||||
// AirRescuerProfile stores role-specific data for role=air_rescuer.
|
||||
type AirRescuerProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
ResponsibleFlightRescuer bool `gorm:"not null;default:false;column:responsible_flight_rescuer"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (AirRescuerProfile) TableName() string { return "air_rescuer_profiles" }
|
||||
|
||||
// TechnicianProfile stores role-specific data for role=technician.
|
||||
type TechnicianProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
|
||||
IsChiefTechnician bool `gorm:"not null;default:false;column:is_chief_technician"`
|
||||
IsResponsibleComplaints bool `gorm:"not null;default:false;column:is_responsible_complaints"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (TechnicianProfile) TableName() string { return "technician_profiles" }
|
||||
|
||||
// FlightAssistantProfile stores role-specific data for role=flight_assistant.
|
||||
type FlightAssistantProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (FlightAssistantProfile) TableName() string { return "flight_assistant_profiles" }
|
||||
|
||||
// StaffProfile stores role-specific data for role=staff.
|
||||
type StaffProfile struct {
|
||||
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
|
||||
ShortName string `gorm:"type:varchar(120);column:short_name"`
|
||||
RoleLabel string `gorm:"type:varchar(120);column:role_label"`
|
||||
Location string `gorm:"type:varchar(255);column:location"`
|
||||
Postcode string `gorm:"type:varchar(32);column:postcode"`
|
||||
StreetLine string `gorm:"type:varchar(255);column:street_line"`
|
||||
Note string `gorm:"type:text;column:note"`
|
||||
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (StaffProfile) TableName() string { return "staff_profiles" }
|
||||
34
internal/domain/auth/email_login_otp.go
Normal file
34
internal/domain/auth/email_login_otp.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserEmailLoginOTP struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
OTPHash []byte `gorm:"type:binary(32);not null;column:otp_hash"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
UsedAt *time.Time `gorm:"index;column:used_at"`
|
||||
AttemptCount int `gorm:"not null;default:0;column:attempt_count"`
|
||||
MaxAttempts int `gorm:"not null;default:5;column:max_attempts"`
|
||||
ResendCount int `gorm:"not null;default:1;column:resend_count"`
|
||||
LastSentAt time.Time `gorm:"index;not null;column:last_sent_at"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserEmailLoginOTP) TableName() string { return "user_email_login_otps" }
|
||||
|
||||
func (o *UserEmailLoginOTP) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(o.ID) == 0 {
|
||||
o.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
143
internal/domain/auth/email_outbox.go
Normal file
143
internal/domain/auth/email_outbox.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
"wucher/internal/queue"
|
||||
)
|
||||
|
||||
const (
|
||||
EmailOutboxStatusPending = "pending"
|
||||
EmailOutboxStatusProcessing = "processing"
|
||||
EmailOutboxStatusPublished = "published"
|
||||
EmailOutboxStatusDead = "dead"
|
||||
)
|
||||
|
||||
type EmailOutboxMessage struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
MessageID string `gorm:"type:varchar(64);uniqueIndex;not null;column:message_id"`
|
||||
Kind string `gorm:"type:varchar(64);index;not null;column:kind"`
|
||||
JobType string `gorm:"type:varchar(64);index;not null;column:job_type"`
|
||||
Body []byte `gorm:"type:longblob;not null;column:body"`
|
||||
Attributes []byte `gorm:"type:longblob;column:attributes"`
|
||||
MessageGroupID string `gorm:"type:varchar(128);column:message_group_id"`
|
||||
DeduplicationID string `gorm:"type:varchar(128);column:deduplication_id"`
|
||||
Status string `gorm:"type:varchar(32);index;not null;column:status"`
|
||||
Attempts int `gorm:"not null;default:0;column:attempts"`
|
||||
AvailableAt time.Time `gorm:"index;not null;column:available_at"`
|
||||
LockedAt *time.Time `gorm:"index;column:locked_at"`
|
||||
LockedBy string `gorm:"type:varchar(64);column:locked_by"`
|
||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||
LastError string `gorm:"type:text;column:last_error"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index;column:correlation_id"`
|
||||
TraceID string `gorm:"type:varchar(64);index;column:trace_id"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (EmailOutboxMessage) TableName() string { return "email_outbox_messages" }
|
||||
|
||||
func (m *EmailOutboxMessage) BeforeCreate(_ *gorm.DB) error {
|
||||
if len(m.ID) == 0 {
|
||||
m.ID = uuidv7.MustBytes()
|
||||
}
|
||||
if strings.TrimSpace(m.Status) == "" {
|
||||
m.Status = EmailOutboxStatusPending
|
||||
}
|
||||
if m.AvailableAt.IsZero() {
|
||||
m.AvailableAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewEmailOutboxMessage(now time.Time, message *queue.OutboundMessage) (*EmailOutboxMessage, error) {
|
||||
if message == nil {
|
||||
return nil, errors.New("outbound message is required")
|
||||
}
|
||||
if strings.TrimSpace(message.ID) == "" {
|
||||
return nil, errors.New("outbound message id is required")
|
||||
}
|
||||
if len(message.Body) == 0 {
|
||||
return nil, errors.New("outbound message body is required")
|
||||
}
|
||||
|
||||
attributes, err := json.Marshal(message.Attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
kind := strings.TrimSpace(message.Attributes["message_kind"])
|
||||
if kind == "" {
|
||||
kind = queue.EmailKind
|
||||
}
|
||||
jobType := strings.TrimSpace(message.Attributes["job_type"])
|
||||
if jobType == "" {
|
||||
jobType = "generic"
|
||||
}
|
||||
|
||||
return &EmailOutboxMessage{
|
||||
MessageID: strings.TrimSpace(message.ID),
|
||||
Kind: kind,
|
||||
JobType: jobType,
|
||||
Body: append([]byte(nil), message.Body...),
|
||||
Attributes: append([]byte(nil), attributes...),
|
||||
MessageGroupID: strings.TrimSpace(message.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(message.DeduplicationID),
|
||||
Status: EmailOutboxStatusPending,
|
||||
AvailableAt: now.UTC(),
|
||||
CorrelationID: strings.TrimSpace(message.Attributes["correlation_id"]),
|
||||
TraceID: strings.TrimSpace(message.Attributes["trace_id"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *EmailOutboxMessage) OutboundMessage() (*queue.OutboundMessage, error) {
|
||||
if m == nil {
|
||||
return nil, errors.New("email outbox message is required")
|
||||
}
|
||||
attributes := map[string]string{}
|
||||
if len(m.Attributes) > 0 {
|
||||
if err := json.Unmarshal(m.Attributes, &attributes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.MessageID) == "" {
|
||||
return nil, errors.New("message id is required")
|
||||
}
|
||||
if len(m.Body) == 0 {
|
||||
return nil, errors.New("message body is required")
|
||||
}
|
||||
return &queue.OutboundMessage{
|
||||
ID: m.MessageID,
|
||||
Body: append([]byte(nil), m.Body...),
|
||||
Attributes: attributes,
|
||||
MessageGroupID: strings.TrimSpace(m.MessageGroupID),
|
||||
DeduplicationID: strings.TrimSpace(m.DeduplicationID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type EmailOutboxWriter interface {
|
||||
CreateEmailOutboxMessage(ctx context.Context, message *EmailOutboxMessage) error
|
||||
}
|
||||
|
||||
type EmailOutboxRepository interface {
|
||||
EmailOutboxWriter
|
||||
ClaimPendingEmailOutboxMessages(ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]EmailOutboxMessage, error)
|
||||
MarkEmailOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error
|
||||
MarkEmailOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error
|
||||
MarkEmailOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error
|
||||
CountPendingEmailOutboxMessages(ctx context.Context, now time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type TransactionalRepository interface {
|
||||
WithTransaction(ctx context.Context, fn func(repo Repository, outbox EmailOutboxWriter) error) error
|
||||
}
|
||||
31
internal/domain/auth/identity.go
Normal file
31
internal/domain/auth/identity.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserIdentity struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_identity_user;not null;column:user_id"`
|
||||
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_provider_subject;not null;column:provider"`
|
||||
ProviderSubject string `gorm:"type:varchar(191);uniqueIndex:ux_provider_subject;not null;column:provider_subject"`
|
||||
Email string `gorm:"type:varchar(191);index;column:email"`
|
||||
Metadata []byte `gorm:"type:json;column:metadata"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserIdentity) TableName() string { return "user_identities" }
|
||||
|
||||
func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(ui.ID) == 0 {
|
||||
ui.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
40
internal/domain/auth/microsoft_oauth_token.go
Normal file
40
internal/domain/auth/microsoft_oauth_token.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type UserMicrosoftOAuthToken struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:user_id"`
|
||||
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:provider"`
|
||||
TenantID string `gorm:"type:varchar(128);index;not null;column:tenant_id"`
|
||||
MicrosoftObjectID string `gorm:"type:varchar(191);not null;column:microsoft_object_id"`
|
||||
MicrosoftSubject string `gorm:"type:varchar(191);not null;column:microsoft_subject"`
|
||||
MicrosoftSessionID string `gorm:"type:varchar(191);column:microsoft_session_id"`
|
||||
RefreshTokenEncrypted []byte `gorm:"type:blob;not null;column:refresh_token_encrypted"`
|
||||
AccessTokenEncrypted []byte `gorm:"type:blob;column:access_token_encrypted"`
|
||||
IDTokenEncrypted []byte `gorm:"type:blob;column:id_token_encrypted"`
|
||||
Scope string `gorm:"type:text;column:scope"`
|
||||
TokenType string `gorm:"type:varchar(32);column:token_type"`
|
||||
AccessTokenExpiresAt *time.Time `gorm:"column:access_token_expires_at"`
|
||||
RefreshTokenExpiresAt *time.Time `gorm:"column:refresh_token_expires_at"`
|
||||
RefreshTokenRotatedAt *time.Time `gorm:"column:refresh_token_rotated_at"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (UserMicrosoftOAuthToken) TableName() string { return "user_microsoft_oauth_tokens" }
|
||||
|
||||
func (t *UserMicrosoftOAuthToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(t.ID) == 0 {
|
||||
t.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
internal/domain/auth/password_reset_token.go
Normal file
32
internal/domain/auth/password_reset_token.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
|
||||
TokenHash []byte `gorm:"type:varbinary(32);uniqueIndex;not null;column:token_hash"`
|
||||
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
|
||||
UsedAt *time.Time `gorm:"index;column:used_at"`
|
||||
RequestedIP string `gorm:"type:varchar(128);column:requested_ip"`
|
||||
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
|
||||
|
||||
func (prt *PasswordResetToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(prt.ID) == 0 {
|
||||
prt.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
internal/domain/auth/permission.go
Normal file
32
internal/domain/auth/permission.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type Permission struct {
|
||||
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
|
||||
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
|
||||
Key string `gorm:"type:varchar(64);uniqueIndex;not null;column:key"`
|
||||
Description string `gorm:"type:varchar(255);column:description"`
|
||||
Module string `gorm:"type:varchar(64);index;column:module"`
|
||||
RequiresPIN bool `gorm:"not null;default:false;column:requires_pin"`
|
||||
|
||||
CreatedAt time.Time
|
||||
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "permissions" }
|
||||
|
||||
func (p *Permission) BeforeCreate(tx *gorm.DB) error {
|
||||
if len(p.ID) == 0 {
|
||||
p.ID = uuidv7.MustBytes()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
129
internal/domain/auth/permission_module.go
Normal file
129
internal/domain/auth/permission_module.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ModuleUser = "user"
|
||||
ModuleRole = "role"
|
||||
ModuleHelicopter = "helicopter"
|
||||
ModuleHelicopterUsage = "helicopter_usage"
|
||||
ModuleReserveAC = "reserve_ac"
|
||||
ModuleComplaint = "complaint"
|
||||
ModuleActionSignoff = "action_signoff"
|
||||
ModuleMCF = "mcf"
|
||||
ModuleEASARelease = "easa_release"
|
||||
ModuleFlightInspection = "flight_inspection"
|
||||
ModuleAfterFlight = "after_flight"
|
||||
ModuleFlight = "flight"
|
||||
ModuleFlightPrepCheck = "flight_prep_check"
|
||||
ModuleDutyRoster = "duty_roster"
|
||||
ModuleDUL = "dul"
|
||||
ModuleHEMSOperation = "hems_operation"
|
||||
ModuleHEMSBase = "hems_base"
|
||||
ModuleOperation = "operation"
|
||||
ModuleOperationCategory = "operation_category"
|
||||
ModuleForcesPresent = "forces_present"
|
||||
ModuleAirRescuerChecklist = "air_rescuer_checklist"
|
||||
ModuleMedicine = "medicine"
|
||||
ModulePatientData = "patient_data"
|
||||
ModuleInsurancePatientData = "insurance_patient_data"
|
||||
ModuleHealthInsuranceCompanies = "health_insurance_companies"
|
||||
ModuleVocation = "vocation"
|
||||
ModuleBase = "base"
|
||||
ModuleFacility = "facility"
|
||||
ModuleCountry = "country"
|
||||
ModuleFederalState = "federal_state"
|
||||
ModuleICAO = "icao"
|
||||
ModuleNoICAOCode = "no_icao_code"
|
||||
ModuleOPC = "opc"
|
||||
ModuleMasterSettings = "master_settings"
|
||||
ModuleFileManager = "file_manager"
|
||||
ModuleFilesystem = "filesystem"
|
||||
ModuleAudit = "audit"
|
||||
)
|
||||
|
||||
type ModuleInfo struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
var ModuleRegistry = []ModuleInfo{
|
||||
{ModuleUser, "User"},
|
||||
{ModuleRole, "Role"},
|
||||
{ModuleHelicopter, "Helicopter"},
|
||||
{ModuleHelicopterUsage, "Helicopter Usage"},
|
||||
{ModuleReserveAC, "Reserve AC"},
|
||||
{ModuleComplaint, "Complaint"},
|
||||
{ModuleActionSignoff, "Action Sign-off"},
|
||||
{ModuleMCF, "MCF"},
|
||||
{ModuleEASARelease, "EASA Release"},
|
||||
{ModuleFlightInspection, "Flight Inspection"},
|
||||
{ModuleAfterFlight, "After Flight Inspection"},
|
||||
{ModuleFlight, "Flight"},
|
||||
{ModuleFlightPrepCheck, "Flight Prep Check"},
|
||||
{ModuleDutyRoster, "Duty Roster"},
|
||||
{ModuleDUL, "DUL"},
|
||||
{ModuleHEMSOperation, "HEMS Operation"},
|
||||
{ModuleHEMSBase, "HEMS Base"},
|
||||
{ModuleOperation, "Operation"},
|
||||
{ModuleOperationCategory, "Operation Category"},
|
||||
{ModuleForcesPresent, "Forces Present"},
|
||||
{ModuleAirRescuerChecklist, "Air Rescuer Checklist"},
|
||||
{ModuleMedicine, "Medicine"},
|
||||
{ModulePatientData, "Patient Data"},
|
||||
{ModuleInsurancePatientData, "Insurance Patient Data"},
|
||||
{ModuleHealthInsuranceCompanies, "Health Insurance Companies"},
|
||||
{ModuleVocation, "Vocation"},
|
||||
{ModuleBase, "Base"},
|
||||
{ModuleFacility, "Facility"},
|
||||
{ModuleCountry, "Country"},
|
||||
{ModuleFederalState, "Federal State"},
|
||||
{ModuleICAO, "ICAO"},
|
||||
{ModuleNoICAOCode, "No ICAO Code"},
|
||||
{ModuleOPC, "OPC"},
|
||||
{ModuleMasterSettings, "Master Settings"},
|
||||
{ModuleFileManager, "File Manager"},
|
||||
{ModuleFilesystem, "Filesystem"},
|
||||
{ModuleAudit, "Audit"},
|
||||
}
|
||||
|
||||
var moduleOrder = func() map[string]int {
|
||||
m := make(map[string]int, len(ModuleRegistry))
|
||||
for i := range ModuleRegistry {
|
||||
m[ModuleRegistry[i].Key] = i
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// ModuleLabel returns the display label for a module key, falling back to the
|
||||
// key itself when the module is not registered.
|
||||
func ModuleLabel(key string) string {
|
||||
if i, ok := moduleOrder[key]; ok {
|
||||
return ModuleRegistry[i].Label
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// IsRegisteredModule reports whether key is a known module in ModuleRegistry.
|
||||
func IsRegisteredModule(key string) bool {
|
||||
_, ok := moduleOrder[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ModuleSortIndex returns a module's position in ModuleRegistry, or a large
|
||||
// value (sorting unknown modules last) when it is not registered.
|
||||
func ModuleSortIndex(key string) int {
|
||||
if idx, ok := moduleOrder[key]; ok {
|
||||
return idx
|
||||
}
|
||||
return len(ModuleRegistry)
|
||||
}
|
||||
|
||||
// ModuleForKey derives the module from a permission key by taking the segment
|
||||
// before the first dot, e.g. "dul.create" -> "dul", "role.permission.read" -> "role".
|
||||
func ModuleForKey(permissionKey string) string {
|
||||
if i := strings.Index(permissionKey, "."); i >= 0 {
|
||||
return permissionKey[:i]
|
||||
}
|
||||
return permissionKey
|
||||
}
|
||||
49
internal/domain/auth/permission_module_test.go
Normal file
49
internal/domain/auth/permission_module_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModuleRegistryIsConsistent(t *testing.T) {
|
||||
seen := make(map[string]bool, len(ModuleRegistry))
|
||||
for _, m := range ModuleRegistry {
|
||||
if m.Key == "" {
|
||||
t.Errorf("registry has an entry with empty key (label %q)", m.Label)
|
||||
}
|
||||
if m.Label == "" {
|
||||
t.Errorf("module %q has an empty label", m.Key)
|
||||
}
|
||||
if seen[m.Key] {
|
||||
t.Errorf("duplicate module key %q in ModuleRegistry", m.Key)
|
||||
}
|
||||
seen[m.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleForKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"dul.create": "dul",
|
||||
"role.permission.read": "role",
|
||||
"file_manager.file.read": "file_manager",
|
||||
"audit.read": "audit",
|
||||
"filesystem": "filesystem",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ModuleForKey(in); got != want {
|
||||
t.Errorf("ModuleForKey(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleLabelAndRegistration(t *testing.T) {
|
||||
if got := ModuleLabel(ModuleDUL); got != "DUL" {
|
||||
t.Errorf("ModuleLabel(%q) = %q, want %q", ModuleDUL, got, "DUL")
|
||||
}
|
||||
if got := ModuleLabel("unknown_module"); got != "unknown_module" {
|
||||
t.Errorf("ModuleLabel fallback = %q, want the key itself", got)
|
||||
}
|
||||
if !IsRegisteredModule(ModuleComplaint) {
|
||||
t.Errorf("expected %q to be a registered module", ModuleComplaint)
|
||||
}
|
||||
if IsRegisteredModule("unknown_module") {
|
||||
t.Errorf("did not expect %q to be registered", "unknown_module")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user