Compare commits
3
Commits
1a48f0541a
...
950a5a51a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950a5a51a7 | ||
|
|
d52cac1ec6 | ||
|
|
0f9b68a0e8 |
@@ -15,6 +15,7 @@ type APIKey struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
User User `json:"user,omitzero"`
|
// User.APIKeys leads back here, and the JSON could loop.
|
||||||
|
User User `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ type Delivery struct {
|
|||||||
// the index.
|
// the index.
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
Event Event `json:"event,omitzero"`
|
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||||
Target Target `json:"target,omitzero"`
|
// JSON could loop.
|
||||||
|
Event Event `json:"-"`
|
||||||
|
Target Target `json:"-"`
|
||||||
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type DeliveryResult struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
Delivery Delivery `json:"delivery,omitzero"`
|
// Delivery.DeliveryResults leads back here, and the JSON could loop.
|
||||||
|
Delivery Delivery `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Entrypoint struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
// Webhook.Entrypoints leads back here, and the JSON could loop.
|
||||||
|
Webhook Webhook `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ type Event struct {
|
|||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to, so
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
// Webhook and Entrypoint are left out of the JSON.
|
||||||
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
Webhook Webhook `json:"-"`
|
||||||
|
Entrypoint Entrypoint `json:"-"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
|
||||||
|
// reference to the record it belongs to is left out of the JSON, so a
|
||||||
|
// webhook and its targets cannot marshal each other in a loop, and that
|
||||||
|
// GORM still preloads that reference, since it ignores json tags.
|
||||||
|
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := startedTestDB(t)
|
||||||
|
|
||||||
|
stored := database.Webhook{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Name: testWebhookName,
|
||||||
|
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
|
||||||
|
Targets: []database.Target{{
|
||||||
|
Name: "log",
|
||||||
|
Type: database.TargetTypeLog,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(&stored).Error)
|
||||||
|
|
||||||
|
entrypointID := stored.Entrypoints[0].ID
|
||||||
|
targetID := stored.Targets[0].ID
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t, db.
|
||||||
|
Preload("Entrypoints.Webhook").
|
||||||
|
Preload("Targets.Webhook").
|
||||||
|
First(&webhook, "id = ?", stored.ID).Error)
|
||||||
|
|
||||||
|
require.Len(t, webhook.Entrypoints, 1)
|
||||||
|
require.Len(t, webhook.Targets, 1)
|
||||||
|
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
|
||||||
|
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
|
||||||
|
|
||||||
|
encoded := marshalModel(t, webhook)
|
||||||
|
|
||||||
|
assert.Contains(t, encoded, entrypointID)
|
||||||
|
assert.Contains(t, encoded, targetID)
|
||||||
|
|
||||||
|
// Each child holds the parent's id as its webhookId, so the parent
|
||||||
|
// is looked for by its own id field.
|
||||||
|
parentIDField := `"id":"` + stored.ID + `"`
|
||||||
|
|
||||||
|
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
|
||||||
|
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
|
||||||
|
|
||||||
|
var target database.Target
|
||||||
|
|
||||||
|
require.NoError(t, db.
|
||||||
|
Preload("Webhook").
|
||||||
|
First(&target, "id = ?", targetID).Error)
|
||||||
|
|
||||||
|
assert.Equal(t, stored.ID, target.Webhook.ID)
|
||||||
|
|
||||||
|
encoded = marshalModel(t, target)
|
||||||
|
|
||||||
|
assert.Contains(t, encoded, stored.ID)
|
||||||
|
assert.NotContains(t, encoded, parentIDField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsMarshalWithoutTheirParent covers the other references to a
|
||||||
|
// parent: each model is built with its parent set, and the parent's id
|
||||||
|
// must not appear in the JSON.
|
||||||
|
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := database.BaseModel{ID: uuid.New().String()}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
model any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Webhook.User",
|
||||||
|
model: database.Webhook{User: database.User{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "APIKey.User",
|
||||||
|
model: database.APIKey{User: database.User{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delivery.Event",
|
||||||
|
model: database.Delivery{Event: database.Event{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delivery.Target",
|
||||||
|
model: database.Delivery{Target: database.Target{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeliveryResult.Delivery",
|
||||||
|
model: database.DeliveryResult{
|
||||||
|
Delivery: database.Delivery{BaseModel: parent},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Event.Webhook",
|
||||||
|
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Event.Entrypoint",
|
||||||
|
model: database.Event{
|
||||||
|
Entrypoint: database.Entrypoint{BaseModel: parent},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,8 @@ type Target struct {
|
|||||||
MaxRetries int `json:"maxRetries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
// Webhook.Targets leads back here, and the JSON could loop.
|
||||||
|
Webhook Webhook `json:"-"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ type Webhook struct {
|
|||||||
// must equal DefaultRetentionDays.
|
// must equal DefaultRetentionDays.
|
||||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||||
|
|
||||||
// Relations
|
// Relations. No model marshals the record it belongs to:
|
||||||
User User `json:"user,omitzero"`
|
// User.Webhooks leads back here, and the JSON could loop.
|
||||||
|
User User `json:"-"`
|
||||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||||
Targets []Target `json:"targets,omitempty"`
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1911,9 +1911,13 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tgt.Active = !tgt.Active
|
// Only the active column: saving the whole row would
|
||||||
|
// write back the name and settings read above over an
|
||||||
|
// edit saved since.
|
||||||
|
active := !tgt.Active
|
||||||
|
|
||||||
return tgt.Active, h.db.DB().Save(&tgt).Error
|
return active, h.db.DB().Model(&tgt).
|
||||||
|
Update("active", active).Error
|
||||||
},
|
},
|
||||||
"failed to toggle target",
|
"failed to toggle target",
|
||||||
targetActivated, targetDeactivated,
|
targetActivated, targetDeactivated,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleTargetToggle_DoesNotUndoAnEdit proves that a toggle which
|
||||||
|
// loaded the target before an edit of it was saved does not write the
|
||||||
|
// old name and settings back over the edit. The edit is submitted from
|
||||||
|
// a callback on the toggle's own read of the target, so it is saved
|
||||||
|
// after that read and before the toggle writes.
|
||||||
|
func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh, tgt := seedHTTPTarget(t, env, "", "")
|
||||||
|
require.True(t, tgt.Active)
|
||||||
|
|
||||||
|
var (
|
||||||
|
edited bool
|
||||||
|
editCode int
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().Callback().Query().
|
||||||
|
After("gorm:query").
|
||||||
|
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
|
||||||
|
// The edit reads the target too; only the toggle's read,
|
||||||
|
// the first, submits it.
|
||||||
|
if tx.Statement.Table != "targets" || edited {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
edited = true
|
||||||
|
editCode = submitTargetEdit(
|
||||||
|
env, wh.ID, tgt.ID,
|
||||||
|
editForm(editReplacedURL, "", ""),
|
||||||
|
).Code
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := postRequest(
|
||||||
|
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/toggle",
|
||||||
|
env.cookies,
|
||||||
|
map[string]string{paramSourceID: wh.ID, paramTargetID: tgt.ID},
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.handlers.HandleTargetToggle().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
require.Equal(t, http.StatusSeeOther, editCode)
|
||||||
|
|
||||||
|
stored := storedTarget(t, env, tgt.ID)
|
||||||
|
assert.False(t, stored.Active)
|
||||||
|
assert.Equal(t, "edited-name", stored.Name)
|
||||||
|
assert.Equal(t, 5, stored.MaxRetries)
|
||||||
|
assert.Equal(
|
||||||
|
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
@@ -78,14 +77,7 @@ func newTestSessionManager(
|
|||||||
key[i] = byte(i)
|
key[i] = byte(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := sessions.NewCookieStore(key)
|
store := session.NewStore(key)
|
||||||
store.Options = &sessions.Options{
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: 86400 * 7,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: false,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
var now func() time.Time
|
var now func() time.Time
|
||||||
|
|
||||||
@@ -931,8 +923,7 @@ func metricsAuthMiddleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := make([]byte, testKeySize)
|
key := make([]byte, testKeySize)
|
||||||
store := sessions.NewCookieStore(key)
|
store := session.NewStore(key)
|
||||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
|
||||||
|
|
||||||
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package session
|
|
||||||
|
|
||||||
import "github.com/gorilla/sessions"
|
|
||||||
|
|
||||||
// NewStore exposes the production cookie-store constructor so tests
|
|
||||||
// exercise the store the application actually runs with, rather than a
|
|
||||||
// lookalike assembled in the test.
|
|
||||||
func NewStore(key []byte) *sessions.CookieStore {
|
|
||||||
return newStore(key)
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,13 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewStore exposes the production cookie-store constructor so tests
|
||||||
|
// exercise the store the application actually runs with, rather than a
|
||||||
|
// lookalike assembled in the test.
|
||||||
|
func NewStore(key []byte) *sessions.CookieStore {
|
||||||
|
return newStore(key)
|
||||||
|
}
|
||||||
|
|
||||||
// NewForTest creates a Session with a pre-configured cookie store for use
|
// NewForTest creates a Session with a pre-configured cookie store for use
|
||||||
// in tests. This bypasses the fx lifecycle and database dependency, allowing
|
// in tests. This bypasses the fx lifecycle and database dependency, allowing
|
||||||
// middleware and handler tests to use real session functionality. The key
|
// middleware and handler tests to use real session functionality. The key
|
||||||
|
|||||||
Reference in New Issue
Block a user