mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
Projects were create-and-read only — PUT, PATCH and DELETE all 405'd — so a project could never be renamed or removed and any naming mistake was permanent. That is how an off-convention project (`console` instead of the <org>-<app> `hanzo-console`) became unfixable. Org-scoped at the store, like every other project method. A cross-tenant delete and a nonexistent id return the SAME not-found, so a caller cannot probe another tenant's ids by telling the two apart. Deleting an already-deleted id is not-found rather than a silent success, so a caller never believes it removed something it did not. Deleting revokes the DSN with no separate step: ResolveIngest can no longer map the id to a tenant, so ingest fails closed exactly as it does for an id that never existed. Pinned by test. Retained events are deliberately NOT deleted — they live in the columnar plane keyed by (org, project), and a naming fix must not double as a history wipe. build + vet clean; implsentry tests pass, incl. 3 new cases (delete, org isolation, DSN stops resolving).
193 lines
6.1 KiB
Go
193 lines
6.1 KiB
Go
package implsentry
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/o11y/pkg/factory/factorytest"
|
|
"github.com/hanzoai/o11y/pkg/sqlstore"
|
|
"github.com/hanzoai/o11y/pkg/sqlstore/sqlitesqlstore"
|
|
"github.com/hanzoai/o11y/pkg/types"
|
|
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newTestSQLStore builds a real sqlite store with the o11y_sentry_projects table and
|
|
// its (org_id, slug) unique index — the shape the migration ships.
|
|
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
|
Provider: "sqlite",
|
|
Connection: sqlstore.ConnectionConfig{
|
|
MaxOpenConns: 1,
|
|
MaxConnLifetime: 0,
|
|
},
|
|
Sqlite: sqlstore.SqliteConfig{
|
|
Path: dbPath,
|
|
Mode: "wal",
|
|
BusyTimeout: 5 * time.Second,
|
|
TransactionMode: "deferred",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = store.BunDB().NewCreateTable().Model((*sentrytypes.Project)(nil)).IfNotExists().Exec(context.Background())
|
|
require.NoError(t, err)
|
|
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_o11y_sentry_projects_org_slug ON o11y_sentry_projects (org_id, slug)`)
|
|
require.NoError(t, err)
|
|
return store
|
|
}
|
|
|
|
func newProject(orgID valuer.UUID, name, slug string) *sentrytypes.Project {
|
|
now := time.Now().UTC()
|
|
return &sentrytypes.Project{
|
|
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
|
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
|
OrgID: orgID,
|
|
Name: name,
|
|
Slug: slug,
|
|
Status: sentrytypes.ProjectActive,
|
|
KeyVersion: 1,
|
|
}
|
|
}
|
|
|
|
func TestProjectStore_CRUDAndRotate(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewProjectStore(newTestSQLStore(t))
|
|
org := valuer.GenerateUUID()
|
|
|
|
p := newProject(org, "Web", "web")
|
|
require.NoError(t, store.Create(ctx, p))
|
|
|
|
got, err := store.Get(ctx, org, p.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "web", got.Slug)
|
|
assert.Equal(t, 1, got.KeyVersion)
|
|
|
|
list, err := store.List(ctx, org)
|
|
require.NoError(t, err)
|
|
require.Len(t, list, 1)
|
|
|
|
// Rotate bumps the key watermark.
|
|
v, err := store.Rotate(ctx, org, p.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, v)
|
|
got, err = store.Get(ctx, org, p.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, got.KeyVersion)
|
|
|
|
// Resolve (ingest path) returns the owning org + watermark.
|
|
rOrg, ver, status, found, err := store.Resolve(ctx, p.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, found)
|
|
assert.Equal(t, org, rOrg)
|
|
assert.Equal(t, 2, ver)
|
|
assert.Equal(t, sentrytypes.ProjectActive, status)
|
|
}
|
|
|
|
// TestProjectStore_TenantIsolation is the mandatory two-org isolation test: org B can
|
|
// neither read nor rotate org A's project, and each org lists only its own.
|
|
func TestProjectStore_TenantIsolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewProjectStore(newTestSQLStore(t))
|
|
orgA, orgB := valuer.GenerateUUID(), valuer.GenerateUUID()
|
|
|
|
pa := newProject(orgA, "A app", "a-app")
|
|
pb := newProject(orgB, "B app", "b-app")
|
|
require.NoError(t, store.Create(ctx, pa))
|
|
require.NoError(t, store.Create(ctx, pb))
|
|
|
|
// org B cannot GET org A's project — foreign id is not found in B's scope.
|
|
_, err := store.Get(ctx, orgB, pa.ID)
|
|
require.Error(t, err)
|
|
|
|
// org B cannot ROTATE org A's project.
|
|
_, err = store.Rotate(ctx, orgB, pa.ID)
|
|
require.Error(t, err)
|
|
// ...and org A's key is untouched by B's attempt.
|
|
stillA, err := store.Get(ctx, orgA, pa.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, stillA.KeyVersion)
|
|
|
|
// Each org lists only its own.
|
|
la, _ := store.List(ctx, orgA)
|
|
lb, _ := store.List(ctx, orgB)
|
|
require.Len(t, la, 1)
|
|
require.Len(t, lb, 1)
|
|
assert.Equal(t, pa.ID, la[0].ID)
|
|
assert.Equal(t, pb.ID, lb[0].ID)
|
|
}
|
|
|
|
func TestProjectStore_ResolveUnknownFailsClosed(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewProjectStore(newTestSQLStore(t))
|
|
_, _, _, found, err := store.Resolve(ctx, valuer.GenerateUUID())
|
|
require.NoError(t, err)
|
|
assert.False(t, found, "an unknown project must resolve to found=false, never a tenant")
|
|
}
|
|
|
|
func TestProjectStore_Delete(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewProjectStore(newTestSQLStore(t))
|
|
org := valuer.GenerateUUID()
|
|
|
|
p := newProject(org, "Gone", "gone")
|
|
require.NoError(t, store.Create(ctx, p))
|
|
require.NoError(t, store.Delete(ctx, org, p.ID))
|
|
|
|
// The row is gone from the org's reads.
|
|
_, err := store.Get(ctx, org, p.ID)
|
|
require.Error(t, err)
|
|
list, err := store.List(ctx, org)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, list)
|
|
|
|
// Deleting again is not-found, not a silent success — the caller learns the id
|
|
// is already gone rather than believing it just removed something.
|
|
require.Error(t, store.Delete(ctx, org, p.ID))
|
|
}
|
|
|
|
func TestProjectStore_DeleteIsOrgScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewProjectStore(newTestSQLStore(t))
|
|
orgA, orgB := valuer.GenerateUUID(), valuer.GenerateUUID()
|
|
|
|
pa := newProject(orgA, "A app", "a-app")
|
|
require.NoError(t, store.Create(ctx, pa))
|
|
|
|
// orgB must not be able to delete orgA's project, and must not be able to tell
|
|
// "exists elsewhere" from "does not exist" — both are not-found.
|
|
require.Error(t, store.Delete(ctx, orgB, pa.ID))
|
|
require.Error(t, store.Delete(ctx, orgB, valuer.GenerateUUID()))
|
|
|
|
// orgA's project survived the cross-tenant attempt.
|
|
got, err := store.Get(ctx, orgA, pa.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "a-app", got.Slug)
|
|
}
|
|
|
|
func TestProjectStore_DeletedProjectStopsResolving(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewProjectStore(newTestSQLStore(t))
|
|
org := valuer.GenerateUUID()
|
|
|
|
p := newProject(org, "Revoked", "revoked")
|
|
require.NoError(t, store.Create(ctx, p))
|
|
|
|
_, _, _, found, err := store.Resolve(ctx, p.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
|
|
// Deleting revokes the DSN: ingest resolution fails closed with no separate
|
|
// revocation step, exactly as it does for an id that never existed.
|
|
require.NoError(t, store.Delete(ctx, org, p.ID))
|
|
_, _, _, found, err = store.Resolve(ctx, p.ID)
|
|
require.NoError(t, err)
|
|
assert.False(t, found)
|
|
}
|