package store_test import ( "slices" "strings" "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/store" ) func setupAuditStore(t *testing.T) *store.SqliteAuditStore { t.Helper() db := setupTestDB(t) tx, err := db.Begin() assert.Ok(t, err) t.Cleanup(func() { tx.Rollback() }) return store.NewSqliteAuditStore(dbi.WrapTx(tx), access.ContextGuard{}) } func TestSqliteAuditStore_AppendAndList(t *testing.T) { s := setupAuditStore(t) ctx := access.Put(t.Context(), access.Admin()) tenant := core.NewID("t") entry := iam.AuditEntry{ Tenant: tenant, Actor: "u-123", Action: "Tenants_Update", Resource: tenant.String(), Detail: "renamed to Acme", Request: "req-1", } assert.Ok(t, s.Append(ctx, &entry)) // Append fills ID and Time. assert.Eq(t, entry.ID.IsEmpty(), false) assert.Eq(t, entry.Time.IsZero(), false) var page core.Page[iam.AuditEntry] assert.Ok(t, s.List(ctx, tenant, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 1) assert.Eq(t, page.Items[0], entry) } func TestSqliteAuditStore_AppendRequiresTenantOrSubject(t *testing.T) { s := setupAuditStore(t) ctx := access.Put(t.Context(), access.Admin()) err := s.Append(ctx, &iam.AuditEntry{Action: "Tenants_Update"}) assert.Eq(t, err, iam.ErrTenantIDEmpty) // Subject-only entries are valid: identity events have no tenant. err = s.Append(ctx, &iam.AuditEntry{Subject: core.NewID("u"), Action: "Identity_Login"}) assert.Ok(t, err) } func TestSqliteAuditStore_ListByUser_SelfAccess(t *testing.T) { s := setupAuditStore(t) adminCtx := access.Put(t.Context(), access.Admin()) me, other := core.NewID("u"), core.NewID("u") assert.Ok(t, s.Append(adminCtx, &iam.AuditEntry{Subject: me, Action: "Identity_Login"})) assert.Ok(t, s.Append(adminCtx, &iam.AuditEntry{Subject: other, Action: "Identity_Login"})) // A principal with no grants at all may still read their own trail. ctx := access.Put(t.Context(), access.Access{}) ctx = iam.PutPrincipal(ctx, iam.Principal{Subject: me.String()}) var page core.Page[iam.AuditEntry] assert.Ok(t, s.ListByUser(ctx, me, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 1) assert.Eq(t, page.Items[0].Subject, me) // But not anyone else's. err := s.ListByUser(ctx, other, core.PageReq{}, &page) assert.Eq(t, err, iam.ErrForbidden) } func TestSqliteAuditStore_ListByUser_SystemAccess(t *testing.T) { s := setupAuditStore(t) adminCtx := access.Put(t.Context(), access.Admin()) user := core.NewID("u") assert.Ok(t, s.Append(adminCtx, &iam.AuditEntry{Subject: user, Action: "Identity_Login"})) // System-scoped Audit.Read may read any user's trail. var acc access.Access acc.GrantSystem(iam.CapAuditRead) ctx := access.Put(t.Context(), acc) var page core.Page[iam.AuditEntry] assert.Ok(t, s.ListByUser(ctx, user, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 1) // Tenant-scoped Audit.Read is not enough for user trails. var scoped access.Access scoped.Grant(iam.CapAuditRead, core.NewID("t"), "") ctx = access.Put(t.Context(), scoped) err := s.ListByUser(ctx, user, core.PageReq{}, &page) assert.Eq(t, err, iam.ErrForbidden) } func TestSqliteAuditStore_ListNewestFirst(t *testing.T) { s := setupAuditStore(t) ctx := access.Put(t.Context(), access.Admin()) tenant := core.NewID("t") var ids []string for range 5 { e := iam.AuditEntry{Tenant: tenant, Action: "Roles_Save"} assert.Ok(t, s.Append(ctx, &e)) ids = append(ids, e.ID.String()) } // IDs are only time-ordered at millisecond resolution, so derive the // expected order by sorting rather than assuming insertion order. slices.SortFunc(ids, func(a, b string) int { return strings.Compare(b, a) }) var page core.Page[iam.AuditEntry] assert.Ok(t, s.List(ctx, tenant, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 5) for i, e := range page.Items { assert.Eq(t, e.ID.String(), ids[i]) } } func TestSqliteAuditStore_ListPagination(t *testing.T) { s := setupAuditStore(t) ctx := access.Put(t.Context(), access.Admin()) tenant := core.NewID("t") for range 5 { e := iam.AuditEntry{Tenant: tenant, Action: "Roles_Save"} assert.Ok(t, s.Append(ctx, &e)) } var page core.Page[iam.AuditEntry] assert.Ok(t, s.List(ctx, tenant, core.PageReq{Limit: 2}, &page)) assert.Eq(t, len(page.Items), 2) var page2 core.Page[iam.AuditEntry] assert.Ok(t, s.List(ctx, tenant, core.PageReq{Limit: 2, Cursor: page.Cursor}, &page2)) assert.Eq(t, len(page2.Items), 2) var page3 core.Page[iam.AuditEntry] assert.Ok(t, s.List(ctx, tenant, core.PageReq{Limit: 2, Cursor: page2.Cursor}, &page3)) assert.Eq(t, len(page3.Items), 1) assert.Eq(t, page3.Cursor, "") // Pages are disjoint and strictly descending across boundaries. assert.Eq(t, page.Items[1].ID.Compare(page2.Items[0].ID), 1) assert.Eq(t, page2.Items[1].ID.Compare(page3.Items[0].ID), 1) } func TestSqliteAuditStore_ListScopedToTenant(t *testing.T) { s := setupAuditStore(t) adminCtx := access.Put(t.Context(), access.Admin()) a, b := core.NewID("t"), core.NewID("t") assert.Ok(t, s.Append(adminCtx, &iam.AuditEntry{Tenant: a, Action: "Roles_Save"})) assert.Ok(t, s.Append(adminCtx, &iam.AuditEntry{Tenant: b, Action: "Roles_Save"})) // A principal with Audit.Read on tenant A only. var acc access.Access acc.Grant(iam.CapAuditRead, a, "") ctx := access.Put(t.Context(), acc) var page core.Page[iam.AuditEntry] assert.Ok(t, s.List(ctx, a, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 1) assert.Eq(t, page.Items[0].Tenant, a) err := s.List(ctx, b, core.PageReq{}, &page) assert.Eq(t, err, iam.ErrForbidden) } func TestSqliteAuditStore_ListNoAccess(t *testing.T) { s := setupAuditStore(t) var page core.Page[iam.AuditEntry] err := s.List(t.Context(), core.NewID("t"), core.PageReq{}, &page) assert.Eq(t, err, iam.ErrForbidden) }