package store_test import ( "context" "database/sql" "encoding/json" "errors" "testing" "atlas9.dev/c/core" "atlas9.dev/c/core/assert" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/lib/access" "atlas9.dev/c/demo/lib/todos" "atlas9.dev/c/demo/lib/webhooks" "atlas9.dev/c/demo/store" ) // seedTenant inserts a tenant row so webhook_endpoints FKs are satisfied, plus an // entitlements row with headroom: the quota_check triggers are fail-closed, so a // tenant without entitlements can create nothing. func seedTenant(t *testing.T, db *sql.DB) core.ID { t.Helper() id := core.NewID("t") _, err := db.ExecContext(context.Background(), `INSERT INTO tenants (id, name) VALUES ($1, $2)`, id, "t") assert.Ok(t, err) _, err = db.ExecContext(context.Background(), `INSERT INTO entitlements (tenant, webhooks_max, slack_max) VALUES ($1, 100, 100)`, id) assert.Ok(t, err) return id } // seedEndpoint inserts a webhook endpoint directly, bypassing the store's // sealed-secret guard so read-path tests don't need a real encryptor. func seedEndpoint(t *testing.T, db *sql.DB, e webhooks.Endpoint) { t.Helper() types, err := json.Marshal(e.EventTypes) assert.Ok(t, err) _, err = db.ExecContext(context.Background(), ` INSERT INTO webhook_endpoints (id, tenant, name, url, event_types, active, dek_id, secret_enc) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, e.ID, e.Tenant, e.Name, e.URL, string(types), e.Active, core.NewID("dek"), []byte("dummy")) assert.Ok(t, err) } func webhookStore(db *sql.DB, tx dbi.DBI) webhooks.Store { return store.NewSqliteWebhookStore(tx, access.AllowAll{}) } func TestWebhookStore_GetEndpoint_RoundTrip(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) ctx := context.Background() ep := webhooks.Endpoint{ ID: core.NewID("whk"), Tenant: tenant, Name: "ci", URL: "https://example.test/hook", EventTypes: []string{todos.EventItemCreated, todos.EventItemUpdated}, Active: true, } seedEndpoint(t, db, ep) var got webhooks.Endpoint assert.Ok(t, dbi.ReadOnly(ctx, db, func(tx dbi.DBI) error { return webhookStore(db, tx).GetEndpoint(ctx, tenant, ep.ID, &got) })) assert.Eq(t, got.Name, "ci") assert.Eq(t, got.URL, "https://example.test/hook") // EventTypes survives the JSON column round-trip. assert.Eq(t, len(got.EventTypes), 2) assert.Eq(t, got.EventTypes[0], todos.EventItemCreated) } func TestWebhookStore_GetEndpoint_NotFound(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) ctx := context.Background() var got webhooks.Endpoint err := dbi.ReadOnly(ctx, db, func(tx dbi.DBI) error { return webhookStore(db, tx).GetEndpoint(ctx, tenant, core.NewID("whk"), &got) }) assert.Eq(t, errors.Is(err, core.ErrNotFound), true) } func TestWebhookStore_ListActiveForEvent(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) other := seedTenant(t, db) ctx := context.Background() // Subscribed and active: should match. match := webhooks.Endpoint{ID: core.NewID("whk"), Tenant: tenant, Name: "a", URL: "u", EventTypes: []string{todos.EventItemCreated}, Active: true} // Subscribed to the type but inactive: excluded. inactive := webhooks.Endpoint{ID: core.NewID("whk"), Tenant: tenant, Name: "b", URL: "u", EventTypes: []string{todos.EventItemCreated}, Active: false} // Active but subscribed to a different type: excluded. otherType := webhooks.Endpoint{ID: core.NewID("whk"), Tenant: tenant, Name: "c", URL: "u", EventTypes: []string{todos.EventItemUpdated}, Active: true} // Right type and active, but a different tenant: excluded. otherTenant := webhooks.Endpoint{ID: core.NewID("whk"), Tenant: other, Name: "d", URL: "u", EventTypes: []string{todos.EventItemCreated}, Active: true} seedEndpoint(t, db, match) seedEndpoint(t, db, inactive) seedEndpoint(t, db, otherType) seedEndpoint(t, db, otherTenant) var found []webhooks.Endpoint assert.Ok(t, dbi.ReadOnly(ctx, db, func(tx dbi.DBI) error { var err error found, err = webhookStore(db, tx).ListActiveForEvent(ctx, tenant, todos.EventItemCreated) return err })) assert.Eq(t, len(found), 1) assert.Eq(t, found[0].ID, match.ID) } func TestWebhookStore_DeleteEndpoint(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) ctx := context.Background() ep := webhooks.Endpoint{ID: core.NewID("whk"), Tenant: tenant, Name: "a", URL: "u", EventTypes: []string{"x"}, Active: true} seedEndpoint(t, db, ep) assert.Ok(t, dbi.ReadWrite(ctx, db, func(tx dbi.DBI) error { return webhookStore(db, tx).DeleteEndpoint(ctx, tenant, ep.ID) })) var got webhooks.Endpoint err := dbi.ReadOnly(ctx, db, func(tx dbi.DBI) error { return webhookStore(db, tx).GetEndpoint(ctx, tenant, ep.ID, &got) }) assert.Eq(t, errors.Is(err, core.ErrNotFound), true) } // The internal fanout read is guarded too: a caller without the read cap is // refused, even though only fanout calls it today. func TestWebhookStore_ListActiveForEvent_Denied(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) ctx := context.Background() _, err := dbi.Read(ctx, db, func(tx dbi.DBI) ([]webhooks.Endpoint, error) { return store.NewSqliteWebhookStore(tx, access.ContextGuard{}).ListActiveForEvent(ctx, tenant, todos.EventItemCreated) }) assert.Eq(t, errors.Is(err, iam.ErrForbidden), true) } // A plaintext (unsealed) signing secret must never reach the column. func TestWebhookStore_CreateEndpoint_RejectsUnsealedSecret(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) ctx := context.Background() ep := webhooks.Endpoint{ ID: core.NewID("whk"), Tenant: tenant, Name: "a", URL: "u", Active: true, DekID: core.NewID("dek"), SecretEnc: []byte("not-sealed"), } err := dbi.ReadWrite(ctx, db, func(tx dbi.DBI) error { return webhookStore(db, tx).CreateEndpoint(ctx, &ep) }) assert.Eq(t, err != nil, true) } // The guard runs before any write: a caller without the cap is refused. func TestWebhookStore_CreateEndpoint_Denied(t *testing.T) { db := setupTestDB(t) tenant := seedTenant(t, db) ctx := context.Background() ep := webhooks.Endpoint{ID: core.NewID("whk"), Tenant: tenant, Name: "a", URL: "u", Active: true} err := dbi.ReadWrite(ctx, db, func(tx dbi.DBI) error { // A real guard with an empty context grants nothing. return store.NewSqliteWebhookStore(tx, access.ContextGuard{}).CreateEndpoint(ctx, &ep) }) assert.Eq(t, errors.Is(err, iam.ErrForbidden), true) }