mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-09-20 23:22:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c5740f9c | ||
|
|
817b781c88 | ||
|
|
87eaa9e18c | ||
|
|
4f6878099b | ||
|
|
0d8b136b91 | ||
|
|
6de9be0ae7 | ||
|
|
82e923b16e | ||
|
|
9a664593f0 | ||
|
|
6e3124e4e0 |
+113
-102
@@ -306,15 +306,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
|
|
||||||
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "read",
|
||||||
ID: id,
|
Options: options,
|
||||||
Writer: w,
|
ID: id,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
|
||||||
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
|
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
|
||||||
@@ -722,15 +723,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
var nestedResult *common.ProcessResult
|
var nestedResult *common.ProcessResult
|
||||||
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "create",
|
||||||
Data: v,
|
Options: options,
|
||||||
Writer: w,
|
Data: v,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
||||||
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
||||||
@@ -769,15 +771,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
var responseData interface{} = v
|
var responseData interface{} = v
|
||||||
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "create",
|
||||||
Data: v,
|
Options: options,
|
||||||
Writer: w,
|
Data: v,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
||||||
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
||||||
@@ -851,15 +854,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
|
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "create",
|
||||||
Data: item,
|
Options: options,
|
||||||
Writer: w,
|
Data: item,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
||||||
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
||||||
@@ -898,15 +902,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "create",
|
||||||
Data: item,
|
Options: options,
|
||||||
Writer: w,
|
Data: item,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
||||||
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
||||||
@@ -982,15 +987,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "create",
|
||||||
Data: itemMap,
|
Options: options,
|
||||||
Writer: w,
|
Data: itemMap,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
||||||
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
||||||
@@ -1035,15 +1041,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "create",
|
||||||
Data: itemMap,
|
Options: options,
|
||||||
Writer: w,
|
Data: itemMap,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
|
||||||
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
return fmt.Errorf("BeforeCreate hook failed: %w", err)
|
||||||
@@ -1166,16 +1173,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
// they must run before the existence-check select so that select is
|
// they must run before the existence-check select so that select is
|
||||||
// also subject to RLS on this connection/transaction.
|
// also subject to RLS on this connection/transaction.
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "update",
|
||||||
ID: urlID,
|
Options: options,
|
||||||
Data: updates,
|
ID: urlID,
|
||||||
Writer: w,
|
Data: updates,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
|
||||||
@@ -1387,16 +1395,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
|
|
||||||
// Execute BeforeUpdate hooks inside transaction
|
// Execute BeforeUpdate hooks inside transaction
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "update",
|
||||||
ID: itemIDStr,
|
Options: options,
|
||||||
Data: item,
|
ID: itemIDStr,
|
||||||
Writer: w,
|
Data: item,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
|
||||||
@@ -1543,16 +1552,17 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
|||||||
|
|
||||||
// Execute BeforeUpdate hooks inside transaction
|
// Execute BeforeUpdate hooks inside transaction
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
Options: options,
|
Operation: "update",
|
||||||
ID: itemIDStr,
|
Options: options,
|
||||||
Data: itemMap,
|
ID: itemIDStr,
|
||||||
Writer: w,
|
Data: itemMap,
|
||||||
Tx: tx,
|
Writer: w,
|
||||||
|
Tx: tx,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
|
||||||
@@ -1648,15 +1658,16 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
|
|
||||||
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
|
// Execute BeforeDelete hooks (covers model-rule checks before any deletion)
|
||||||
hookCtx := &HookContext{
|
hookCtx := &HookContext{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Model: model,
|
Model: model,
|
||||||
ID: id,
|
Operation: "delete",
|
||||||
Data: data,
|
ID: id,
|
||||||
Writer: w,
|
Data: data,
|
||||||
Tx: h.db,
|
Writer: w,
|
||||||
|
Tx: h.db,
|
||||||
}
|
}
|
||||||
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
|
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
|
||||||
logger.Error("BeforeDelete hook failed: %v", err)
|
logger.Error("BeforeDelete hook failed: %v", err)
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
|
|||||||
// Hook 1: BeforeRead - Load security rules
|
// Hook 1: BeforeRead - Load security rules
|
||||||
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
|
handler.Hooks().Register(BeforeRead, func(hookCtx *HookContext) error {
|
||||||
secCtx := newSecurityContext(hookCtx)
|
secCtx := newSecurityContext(hookCtx)
|
||||||
|
if security.IsModelSecurityDisabled(secCtx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return security.LoadSecurityRules(secCtx, securityList)
|
return security.LoadSecurityRules(secCtx, securityList)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hook 2: BeforeScan - Apply row-level security filters
|
// Hook 2: BeforeScan - Apply row-level security filters
|
||||||
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
|
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
|
||||||
secCtx := newSecurityContext(hookCtx)
|
secCtx := newSecurityContext(hookCtx)
|
||||||
|
if security.ShouldSkipRowSecurity(secCtx, hookCtx.Operation) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return security.ApplyRowSecurity(secCtx, securityList)
|
return security.ApplyRowSecurity(secCtx, securityList)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,6 +103,10 @@ func (s *securityContext) GetEntity() string {
|
|||||||
return s.ctx.Entity
|
return s.ctx.Entity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *securityContext) GetOperation() string {
|
||||||
|
return s.ctx.Operation
|
||||||
|
}
|
||||||
|
|
||||||
func (s *securityContext) GetModel() interface{} {
|
func (s *securityContext) GetModel() interface{} {
|
||||||
return s.ctx.Model
|
return s.ctx.Model
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,3 +137,39 @@ func TestApplyFilter_Citext_NeverCastForEqOrIlike(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike reproduces a
|
||||||
|
// global "search all columns" request (x-searchor-contains-<col> per column,
|
||||||
|
// e.g. the X-Filter-All style OR group) landing an ILIKE filter with a
|
||||||
|
// '%...%'-wrapped numeric-looking value on a numeric column such as
|
||||||
|
// rid_parent. Before the fix, ValidateAndAdjustFilterForColumnType trimmed
|
||||||
|
// the '%' wildcards, saw a numeric string, and rewrote filter.Value to an
|
||||||
|
// int64 -- so applyFilter's CAST(col AS TEXT) ILIKE ? bound an integer
|
||||||
|
// argument instead of the wildcard string, and Postgres rejected it with
|
||||||
|
// "operator does not exist: text ~~* integer".
|
||||||
|
func TestValidateAndAdjustFilterForColumnType_NumericColumn_Ilike(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
model := atdetailModel{}
|
||||||
|
|
||||||
|
filter := &common.FilterOption{Column: "rid_parent", Operator: "ilike", Value: "%345346346%"}
|
||||||
|
info := h.ValidateAndAdjustFilterForColumnType(filter, model)
|
||||||
|
|
||||||
|
if !info.NeedsCast {
|
||||||
|
t.Fatalf("expected NeedsCast=true so the numeric column is cast to TEXT for ILIKE")
|
||||||
|
}
|
||||||
|
if filter.Value != "%345346346%" {
|
||||||
|
t.Fatalf("ILIKE must keep the wildcard-wrapped string value untouched, got %#v", filter.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := &jsonCapQuery{}
|
||||||
|
h.applyFilter(q, *filter, "public.atdetail", info.NeedsCast, "OR", model)
|
||||||
|
|
||||||
|
c := q.only(t)
|
||||||
|
const want = "CAST(atdetail.rid_parent AS TEXT) ILIKE ?"
|
||||||
|
if c.query != want {
|
||||||
|
t.Fatalf("query = %q, want %q", c.query, want)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(c.args, []interface{}{"%345346346%"}) {
|
||||||
|
t.Fatalf("args = %#v, want [\"%%345346346%%\"]", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "read",
|
||||||
Options: options,
|
Options: options,
|
||||||
ID: id,
|
ID: id,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
@@ -1236,6 +1237,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "create",
|
||||||
Options: options,
|
Options: options,
|
||||||
Data: data,
|
Data: data,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
@@ -1335,6 +1337,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "create",
|
||||||
Options: options,
|
Options: options,
|
||||||
Data: modelValue,
|
Data: modelValue,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
@@ -1489,6 +1492,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Tx: tx,
|
Tx: tx,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "update",
|
||||||
Options: options,
|
Options: options,
|
||||||
ID: id,
|
ID: id,
|
||||||
Data: dataMap,
|
Data: dataMap,
|
||||||
@@ -1686,6 +1690,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "delete",
|
||||||
ID: itemID,
|
ID: itemID,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
Tx: tx,
|
Tx: tx,
|
||||||
@@ -1760,6 +1765,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "delete",
|
||||||
ID: itemIDStr,
|
ID: itemIDStr,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
Tx: tx,
|
Tx: tx,
|
||||||
@@ -1818,6 +1824,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "delete",
|
||||||
ID: itemIDStr,
|
ID: itemIDStr,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
Tx: tx,
|
Tx: tx,
|
||||||
@@ -1902,6 +1909,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
Entity: entity,
|
Entity: entity,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
Model: model,
|
Model: model,
|
||||||
|
Operation: "delete",
|
||||||
ID: id,
|
ID: id,
|
||||||
Writer: w,
|
Writer: w,
|
||||||
Tx: h.db,
|
Tx: h.db,
|
||||||
|
|||||||
@@ -1479,6 +1479,18 @@ func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOpti
|
|||||||
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LIKE/ILIKE always compare against text, wildcards and all. Never coerce
|
||||||
|
// the value to the column's native numeric/bool/time type here: doing so
|
||||||
|
// strips the '%' wildcards and hands the driver a non-string argument,
|
||||||
|
// which fails with "operator does not exist: text ~~* integer" once the
|
||||||
|
// column is cast to TEXT below.
|
||||||
|
if op := strings.ToLower(filter.Operator); op == "like" || op == "ilike" {
|
||||||
|
if reflection.IsStringType(colType) {
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
}
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: reflection.IsNumericType(colType)}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the input value is numeric
|
// Check if the input value is numeric
|
||||||
valueIsNumeric := false
|
valueIsNumeric := false
|
||||||
if strVal, ok := filter.Value.(string); ok {
|
if strVal, ok := filter.Value.(string); ok {
|
||||||
|
|||||||
+59
-17
@@ -232,9 +232,37 @@ func LoadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
|
|||||||
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
|
// ApplyRowSecurity is a public wrapper for applyRowSecurity that accepts a SecurityContext
|
||||||
// This allows other packages to apply row-level security using the generic interface
|
// This allows other packages to apply row-level security using the generic interface
|
||||||
func ApplyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
func ApplyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||||
|
// Spec adapters that expose the dispatched operation can enforce the same
|
||||||
|
// model-rule bypass even when ApplyRowSecurity is called directly.
|
||||||
|
if operationCtx, ok := secCtx.(interface{ GetOperation() string }); ok &&
|
||||||
|
ShouldSkipRowSecurity(secCtx, operationCtx.GetOperation()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return applyRowSecurity(secCtx, securityList)
|
return applyRowSecurity(secCtx, securityList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShouldSkipRowSecurity reports whether row-security enforcement should be
|
||||||
|
// skipped for the operation. It uses the same model-rule resolution as
|
||||||
|
// CheckModelAuthAllowed so the model registry remains the single source of
|
||||||
|
// truth for security behavior.
|
||||||
|
func ShouldSkipRowSecurity(secCtx SecurityContext, operation string) bool {
|
||||||
|
rules, ok := resolveModelRules(secCtx)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules.SecurityDisabled || (operation == "read" && rules.CanPublicRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsModelSecurityDisabled reports whether all model-level security processing
|
||||||
|
// is disabled for the model. This is distinct from ShouldSkipRowSecurity:
|
||||||
|
// CanPublicRead skips row filtering for reads but must still allow other read
|
||||||
|
// security, such as column masking, to be loaded.
|
||||||
|
func IsModelSecurityDisabled(secCtx SecurityContext) bool {
|
||||||
|
rules, ok := resolveModelRules(secCtx)
|
||||||
|
return ok && rules.SecurityDisabled
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyColumnSecurity is a public wrapper for applyColumnSecurity that accepts a SecurityContext
|
// ApplyColumnSecurity is a public wrapper for applyColumnSecurity that accepts a SecurityContext
|
||||||
// This allows other packages to apply column-level security using the generic interface
|
// This allows other packages to apply column-level security using the generic interface
|
||||||
func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
func ApplyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||||
@@ -303,25 +331,14 @@ func checkModelDeleteAllowed(secCtx SecurityContext) error {
|
|||||||
// 7. Guest (UserID == 0) → return "authentication required".
|
// 7. Guest (UserID == 0) → return "authentication required".
|
||||||
// 8. Authenticated user → allow (operation-specific checks remain in BeforeUpdate/BeforeDelete).
|
// 8. Authenticated user → allow (operation-specific checks remain in BeforeUpdate/BeforeDelete).
|
||||||
func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
|
func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
|
||||||
rules, ok := GetModelRulesFromContext(secCtx.GetContext())
|
rules, ok := resolveModelRules(secCtx)
|
||||||
if !ok {
|
if !ok {
|
||||||
schema := secCtx.GetSchema()
|
// Model not registered - fall through to auth check
|
||||||
entity := secCtx.GetEntity()
|
userID, _ := secCtx.GetUserID()
|
||||||
var err error
|
if userID == 0 {
|
||||||
if schema != "" {
|
return fmt.Errorf("authentication required")
|
||||||
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
|
|
||||||
}
|
|
||||||
if err != nil || schema == "" {
|
|
||||||
rules, err = modelregistry.GetModelRulesByName(entity)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// Model not registered - fall through to auth check
|
|
||||||
userID, _ := secCtx.GetUserID()
|
|
||||||
if userID == 0 {
|
|
||||||
return fmt.Errorf("authentication required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if rules.SecurityDisabled {
|
if rules.SecurityDisabled {
|
||||||
@@ -347,6 +364,31 @@ func CheckModelAuthAllowed(secCtx SecurityContext, operation string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveModelRules returns model rules from the request context first, then
|
||||||
|
// falls back to the schema-qualified and unqualified registry names.
|
||||||
|
func resolveModelRules(secCtx SecurityContext) (modelregistry.ModelRules, bool) {
|
||||||
|
if rules, ok := GetModelRulesFromContext(secCtx.GetContext()); ok {
|
||||||
|
return rules, true
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := secCtx.GetSchema()
|
||||||
|
entity := secCtx.GetEntity()
|
||||||
|
var err error
|
||||||
|
if schema != "" {
|
||||||
|
var rules modelregistry.ModelRules
|
||||||
|
rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
|
||||||
|
if err == nil {
|
||||||
|
return rules, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := modelregistry.GetModelRulesByName(entity)
|
||||||
|
if err != nil {
|
||||||
|
return modelregistry.ModelRules{}, false
|
||||||
|
}
|
||||||
|
return rules, true
|
||||||
|
}
|
||||||
|
|
||||||
// CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed.
|
// CheckModelUpdateAllowed is the public wrapper for checkModelUpdateAllowed.
|
||||||
func CheckModelUpdateAllowed(secCtx SecurityContext) error {
|
func CheckModelUpdateAllowed(secCtx SecurityContext) error {
|
||||||
return checkModelUpdateAllowed(secCtx)
|
return checkModelUpdateAllowed(secCtx)
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Package quickproxy provides a small reverse-proxy layer that tries a set
|
||||||
|
// of configured upstream targets first, and falls back to a caller-supplied
|
||||||
|
// http.Handler (typically static file serving) when the upstream is
|
||||||
|
// unreachable or returns 404.
|
||||||
|
package quickproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule maps a URL path prefix to an upstream target.
|
||||||
|
// A Rule with URLPrefix "/" acts as a catch-all passthrough.
|
||||||
|
type Rule struct {
|
||||||
|
// URLPrefix is the URL path prefix this rule matches. Must start with "/".
|
||||||
|
URLPrefix string
|
||||||
|
|
||||||
|
// Target is the upstream base URL, e.g. "http://localhost:3000".
|
||||||
|
// The incoming request path and query are forwarded unchanged; only the
|
||||||
|
// scheme and host are rewritten to Target's.
|
||||||
|
Target string
|
||||||
|
|
||||||
|
// Exclude is a list of URL path prefixes that this rule should not
|
||||||
|
// proxy, even though they fall under URLPrefix. Each entry is a full
|
||||||
|
// path from root and must itself start with URLPrefix (e.g. rule
|
||||||
|
// URLPrefix "/api" excluding a subpath must use "/api/health", not
|
||||||
|
// "/health"). A request matching an Exclude prefix is treated as if
|
||||||
|
// this rule didn't match at all: matching continues against any other
|
||||||
|
// configured rule, falling back if none match. This is typically used
|
||||||
|
// to carve out paths (e.g. "/health") from a catch-all "/" rule so
|
||||||
|
// they're served by the fallback handler instead of being proxied.
|
||||||
|
Exclude []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTimeout is the dial and response-header timeout applied to
|
||||||
|
// upstream requests when no WithTimeout option is given. It does not limit
|
||||||
|
// response body streaming.
|
||||||
|
const DefaultTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Option configures a Service.
|
||||||
|
type Option func(*options)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTimeout sets the dial and response-header timeout used when
|
||||||
|
// connecting to upstream targets. It does not limit response body
|
||||||
|
// streaming, so it won't interrupt long-lived downloads or SSE/WebSocket
|
||||||
|
// connections once established.
|
||||||
|
func WithTimeout(d time.Duration) Option {
|
||||||
|
return func(o *options) { o.timeout = d }
|
||||||
|
}
|
||||||
|
|
||||||
|
// compiledRule pairs a Rule with its ready-to-use reverse proxy.
|
||||||
|
type compiledRule struct {
|
||||||
|
prefix string
|
||||||
|
excludes []string
|
||||||
|
proxy *httputil.ReverseProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// excluded reports whether path falls under one of the rule's Exclude prefixes.
|
||||||
|
func (r *compiledRule) excluded(path string) bool {
|
||||||
|
for _, ex := range r.excludes {
|
||||||
|
if strings.HasPrefix(path, ex) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service holds a compiled set of proxy rules and performs longest-prefix
|
||||||
|
// matching against them. A Service is safe for concurrent use once
|
||||||
|
// returned from NewService; Handler must be called once per Service to
|
||||||
|
// wire up the fallback handler before the returned http.Handler is served.
|
||||||
|
type Service struct {
|
||||||
|
rules []compiledRule // sorted by descending prefix length
|
||||||
|
}
|
||||||
|
|
||||||
|
// errUpstreamNotFound is a sentinel error returned from ModifyResponse to
|
||||||
|
// make ReverseProxy invoke ErrorHandler (our fallback path) instead of
|
||||||
|
// writing the upstream's 404 to the client. Nothing has been written to
|
||||||
|
// the ResponseWriter yet when this happens.
|
||||||
|
var errUpstreamNotFound = errors.New("quickproxy: upstream returned 404")
|
||||||
|
|
||||||
|
// NewService compiles the given rules into a Service. Rules are matched by
|
||||||
|
// longest URLPrefix, so a catch-all "/" rule can coexist with more specific
|
||||||
|
// rules such as "/api".
|
||||||
|
func NewService(rules []Rule, opts ...Option) (*Service, error) {
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return nil, fmt.Errorf("quickproxy: no rules configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := options{timeout: DefaultTimeout}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool, len(rules))
|
||||||
|
compiled := make([]compiledRule, 0, len(rules))
|
||||||
|
|
||||||
|
for _, r := range rules {
|
||||||
|
if !strings.HasPrefix(r.URLPrefix, "/") {
|
||||||
|
return nil, fmt.Errorf("quickproxy: rule prefix %q must start with /", r.URLPrefix)
|
||||||
|
}
|
||||||
|
if seen[r.URLPrefix] {
|
||||||
|
return nil, fmt.Errorf("quickproxy: duplicate rule prefix %q", r.URLPrefix)
|
||||||
|
}
|
||||||
|
seen[r.URLPrefix] = true
|
||||||
|
|
||||||
|
target, err := url.Parse(r.Target)
|
||||||
|
if err != nil || target.Scheme == "" || target.Host == "" {
|
||||||
|
return nil, fmt.Errorf("quickproxy: invalid target %q for prefix %q", r.Target, r.URLPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range r.Exclude {
|
||||||
|
if !strings.HasPrefix(ex, "/") {
|
||||||
|
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must start with /", ex, r.URLPrefix)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ex, r.URLPrefix) {
|
||||||
|
return nil, fmt.Errorf("quickproxy: exclude prefix %q for rule %q must itself start with the rule's URLPrefix", ex, r.URLPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledRule{
|
||||||
|
prefix: r.URLPrefix,
|
||||||
|
excludes: r.Exclude,
|
||||||
|
proxy: newReverseProxy(target, cfg.timeout),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest prefix first, so the first match in Handler is always the
|
||||||
|
// most specific one.
|
||||||
|
sort.Slice(compiled, func(i, j int) bool {
|
||||||
|
return len(compiled[i].prefix) > len(compiled[j].prefix)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Service{rules: compiled}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReverseProxy(target *url.URL, timeout time.Duration) *httputil.ReverseProxy {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: timeout,
|
||||||
|
}).DialContext,
|
||||||
|
ResponseHeaderTimeout: timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
|
Director: func(req *http.Request) {
|
||||||
|
originalHost := req.Host
|
||||||
|
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
|
||||||
|
if originalHost != "" {
|
||||||
|
req.Header.Set("X-Forwarded-Host", originalHost)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return errUpstreamNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns an http.Handler that tries the configured proxy rules
|
||||||
|
// first (longest-prefix match), and calls fallback when no rule matches,
|
||||||
|
// the upstream is unreachable, or the upstream returns 404. Any other
|
||||||
|
// upstream response (2xx, other 4xx, 5xx) is streamed through to the
|
||||||
|
// client unchanged.
|
||||||
|
//
|
||||||
|
// Handler wires up ErrorHandler on the Service's compiled rules, so it
|
||||||
|
// should be called once per Service, before the returned http.Handler
|
||||||
|
// starts serving requests.
|
||||||
|
func (s *Service) Handler(fallback http.Handler) http.Handler {
|
||||||
|
if fallback == nil {
|
||||||
|
fallback = http.NotFoundHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range s.rules {
|
||||||
|
s.rules[i].proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, _ error) {
|
||||||
|
fallback.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rule := s.match(r.URL.Path)
|
||||||
|
if rule == nil {
|
||||||
|
fallback.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule.proxy.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// match returns the longest-prefix rule matching path, or nil if none match.
|
||||||
|
// A rule whose Exclude covers path is skipped, and matching continues
|
||||||
|
// against the next-longest-prefix rule.
|
||||||
|
func (s *Service) match(path string) *compiledRule {
|
||||||
|
for i := range s.rules {
|
||||||
|
if !strings.HasPrefix(path, s.rules[i].prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.rules[i].excluded(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &s.rules[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package quickproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewService_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rules []Rule
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"no rules", nil, true},
|
||||||
|
{"empty rules", []Rule{}, true},
|
||||||
|
{"bad prefix", []Rule{{URLPrefix: "api", Target: "http://localhost:1"}}, true},
|
||||||
|
{"bad target", []Rule{{URLPrefix: "/api", Target: "not-a-url"}}, true},
|
||||||
|
{"missing host", []Rule{{URLPrefix: "/api", Target: "http://"}}, true},
|
||||||
|
{"duplicate prefix", []Rule{
|
||||||
|
{URLPrefix: "/api", Target: "http://localhost:1"},
|
||||||
|
{URLPrefix: "/api", Target: "http://localhost:2"},
|
||||||
|
}, true},
|
||||||
|
{"bad exclude prefix", []Rule{
|
||||||
|
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"health"}},
|
||||||
|
}, true},
|
||||||
|
{"exclude outside rule's URLPrefix", []Rule{
|
||||||
|
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/health"}},
|
||||||
|
}, true},
|
||||||
|
{"valid", []Rule{{URLPrefix: "/api", Target: "http://localhost:1"}}, false},
|
||||||
|
{"valid with exclude", []Rule{
|
||||||
|
{URLPrefix: "/", Target: "http://localhost:1", Exclude: []string{"/health"}},
|
||||||
|
}, false},
|
||||||
|
{"valid with nested exclude", []Rule{
|
||||||
|
{URLPrefix: "/api", Target: "http://localhost:1", Exclude: []string{"/api/health"}},
|
||||||
|
}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := NewService(tt.rules)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("NewService() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackHandler(body string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ProxiesSuccessResponse(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/widgets", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "upstream:/api/widgets" {
|
||||||
|
t.Fatalf("body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_404FallsBack(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("upstream not found"))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback-content"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/missing.html", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "fallback-content" {
|
||||||
|
t.Fatalf("body = %q, want fallback-content", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_UnreachableUpstreamFallsBack(t *testing.T) {
|
||||||
|
// A closed listener address: nothing is listening, so dialing fails.
|
||||||
|
unreachable := "http://127.0.0.1:1"
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{{URLPrefix: "/", Target: unreachable}}, WithTimeout(500*time.Millisecond))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback-content"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "fallback-content" {
|
||||||
|
t.Fatalf("body = %q, want fallback-content", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_NonNotFoundErrorsPassThrough(t *testing.T) {
|
||||||
|
codes := []int{http.StatusOK, http.StatusForbidden, http.StatusBadRequest, http.StatusInternalServerError}
|
||||||
|
|
||||||
|
for _, code := range codes {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_, _ = w.Write([]byte("upstream response"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{{URLPrefix: "/", Target: upstream.URL}})
|
||||||
|
if err != nil {
|
||||||
|
upstream.Close()
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback-content"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != code {
|
||||||
|
t.Errorf("status for upstream code %d = %d, want %d", code, rr.Code, code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "upstream response" {
|
||||||
|
t.Errorf("body for upstream code %d = %q, want passthrough", code, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_LongestPrefixMatch(t *testing.T) {
|
||||||
|
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("specific"))
|
||||||
|
}))
|
||||||
|
defer specific.Close()
|
||||||
|
|
||||||
|
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("general"))
|
||||||
|
}))
|
||||||
|
defer general.Close()
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{
|
||||||
|
{URLPrefix: "/", Target: general.URL},
|
||||||
|
{URLPrefix: "/api/v1", Target: specific.URL},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback"))
|
||||||
|
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"/api/v1/thing": "specific",
|
||||||
|
"/api/other": "general",
|
||||||
|
"/anything": "general",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if got := rr.Body.String(); got != want {
|
||||||
|
t.Errorf("path %s: body = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ExcludeFallsBackToFallback(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("upstream:" + r.URL.Path))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{
|
||||||
|
{URLPrefix: "/", Target: upstream.URL, Exclude: []string{"/health"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback-content"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if got := rr.Body.String(); got != "fallback-content" {
|
||||||
|
t.Fatalf("body = %q, want fallback-content", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if got := rr.Body.String(); got != "fallback-content" {
|
||||||
|
t.Fatalf("body = %q, want fallback-content", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/other", nil)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if got := rr.Body.String(); got != "upstream:/other" {
|
||||||
|
t.Fatalf("body = %q, want upstream:/other", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ExcludeFallsThroughToNextRule(t *testing.T) {
|
||||||
|
specific := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("specific"))
|
||||||
|
}))
|
||||||
|
defer specific.Close()
|
||||||
|
|
||||||
|
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("general"))
|
||||||
|
}))
|
||||||
|
defer general.Close()
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{
|
||||||
|
{URLPrefix: "/api", Target: general.URL},
|
||||||
|
{URLPrefix: "/api/v1", Target: specific.URL, Exclude: []string{"/api/v1/health"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback"))
|
||||||
|
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"/api/v1/thing": "specific",
|
||||||
|
"/api/v1/health": "general",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if got := rr.Body.String(); got != want {
|
||||||
|
t.Errorf("path %s: body = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_NoMatchFallsBack(t *testing.T) {
|
||||||
|
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: "http://127.0.0.1:1"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback-content"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/other", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "fallback-content" {
|
||||||
|
t.Fatalf("body = %q, want fallback-content", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_AllMethodsProxied(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(r.Method + ":" + string(body)))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc, err := NewService([]Rule{{URLPrefix: "/api", Target: upstream.URL}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := svc.Handler(fallbackHandler("fallback"))
|
||||||
|
|
||||||
|
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}
|
||||||
|
for _, method := range methods {
|
||||||
|
req := httptest.NewRequest(method, "/api/widgets", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
want := method + ":"
|
||||||
|
if got := rr.Body.String(); got != want {
|
||||||
|
t.Errorf("method %s: body = %q, want %q", method, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user