accesscoretest

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package accesscoretest provides public test-infrastructure helpers for the accesscore cell.

Purpose

Go's internal-package barrier prevents tests/integration/ and other external packages from importing cells/accesscore/internal/ports (the real UserRepository / RoleRepository / ConfigGetter interfaces) or cells/accesscore/internal/domain (the User / Role aggregates). This package lives inside the cells/accesscore subtree, so it can import those internal packages — but its public API exposes only value-type DTOs and builder helpers. External callers (tests/integration/*, journey suites) never need to import any cells/accesscore/internal/* package.

Public API surface (no internal leak)

  • AccessFixture (sealed; constructor NewAccessFixture)
  • SeededUser / SeededRole — value-type seed inputs
  • SeededUserView / SeededRoleView — value-type read outputs
  • UserStatus + UserStatusActive / Suspended / Locked
  • AccessFixture.SeedUser / SeedRole / SeedAssignment
  • AccessFixture.GetUser / GetRole / UserRoles / TxRunner
  • CredentialInvalidator (opaque wrapper) + NewCredentialInvalidator + WithInvalidatorFixture (fixture-only collapse); the wrapper hides *internal/credentialinvalidate.Invalidator from public signatures so no external caller can name the internal type
  • BuildIdentityManageService + With* options
  • BuildConfigReceiveService + With* options
  • FakeConfigGetter + four typed stub constructors: PresentStub / SensitiveStub / NotFoundStub / ErrorStub

Continued absence of internal-type leaks is gated by external_smoke_test.go, which uses a foreign _test package and would stop compiling the moment any public API forces callers to import cells/accesscore/internal/*.

Relationship to cell.go composition root

cells/accesscore/cell.go is the production composition root: it wires real PG adapters, real JWT issuers, and real TxManagers. This package is the test-composition root: it wires in-memory stores and stubs so tests remain docker-free and fast.

Why AccessFixture aggregates via mem.Bundle + holds session/refresh

PR #595 review caught a test helper in cmd/corebundle that wired UserRepository + RoleRepository + SetupLock from different mem stores. Because the effective-admin invariant (S4.0) requires atomic cross-repo operations, silently using two independent stores breaks the invariant without any compile-time or runtime signal.

cells/accesscore/mem.NewBundle is the sealed funnel that vends all four store-paired primitives (UserRepository, RoleRepository, SetupLock, TxRunner) from the same underlying mem.Store. AccessFixture wraps a single Bundle, then adds singleton session.MemStore and refresh.Store instances of its own. Round-2 review of PR #845 added this extension: without the singletons, NewCredentialInvalidator's default fell back to brand-new session/refresh stores from internal/testutil, silently isolating it from any sessionlogin.Service a caller might later inject as TokenIssuer — the same mis-pairing failure PR #595 set out to prevent. NewCredentialInvalidator now only accepts a fixture (WithInvalidatorFixture), making the non-paired wiring path unexpressible.

Debug logger swap

All builders default to slog.New(slog.DiscardHandler). To see verbose output during local debugging, swap in a text handler:

svc, fix, rec := accesscoretest.BuildIdentityManageService(t,
    accesscoretest.WithIdentityLogger(
        slog.New(slog.NewTextHandler(os.Stderr, nil)),
    ),
)

End-to-end example: BuildIdentityManageService

func ExampleBuildIdentityManageService() {
    svc, fix, _ := accesscoretest.BuildIdentityManageService(nil) // nil only in pkg example
    _ = svc
    _ = fix
}

Import scope

This package is test-infrastructure: it must NOT be imported by production Go files. The CELLTEST-IMPORT-SCOPE-01 archtest enforces this automatically for all cells/{X}/{X}test packages.

Tests may import this package freely. The complementary guarantee — that accesscoretest itself does not re-export internal types and therefore does not force its consumers to import cells/accesscore/internal/* — is gated by external_smoke_test.go (Hard funnel: Go internal barrier upstream, compile-time check downstream).

Deferred: spy methods

AccessFixture does not expose CallsOf / Snapshot spy methods. The current design converges on AccessFixture as an aggregate; spy methods will be added when a journey criterion actually needs them.

ref: PR #595 (store-pairing precedent) ref: PR #845 round-2 review (session/refresh pairing + internal-leak closure)

Index

Constants

This section is empty.

Variables

View Source
var DefaultFixtureTenantID = func() tenant.TenantID {
	t, err := tenant.ParseTenantID("00000000-0000-0000-0000-000000000001")
	if err != nil {
		panic(panicregister.Approved("accesscoretest-fixture-tenant",
			errcode.Assertion("accesscoretest: invalid DefaultFixtureTenantID: %v", err)))
	}
	return t
}()

DefaultFixtureTenantID is the canonical test tenant UUID used by AccessFixture for all tenant-scoped repo operations. Tests that need multi-tenant isolation should use the TenantID field on fixture calls directly.

Functions

func BuildConfigReceiveService

func BuildConfigReceiveService(t *testing.T, opts ...BuildConfigReceiveOption) *configreceive.Service

BuildConfigReceiveService constructs a ready-to-use *configreceive.Service.

Default wiring:

  • ConfigGetter: NewFakeConfigGetter(nil) — all keys return ErrConfigRepoNotFound
  • Logger: slog.New(slog.DiscardHandler)
  • ConfigEventCollector: not set — configreceive.NewService falls back to its own noop collector. Inject via WithConfigReceiveCollector to assert metric attribution.

func MinCostPasswordHasherOption

func MinCostPasswordHasherOption() accesscore.Option

MinCostPasswordHasherOption returns an accesscore.Option that wires a low-cost (bcrypt.MinCost) password hasher so test harnesses that build the accesscore cell get fast seedAdmin / login instead of the ~1.5s/hash production cost. Apply it alongside the cell's other options.

This is the sanctioned bridge for external test packages (e.g. the tests/integration harnesses) that cannot import cells/accesscore/internal/credential directly under Go's internal-package rule. It returns the fully public accesscore.Option rather than the internal credential.Hasher, so the public surface carries no internal type — pinned by external_smoke_test.go's typed gate. BCRYPT-COST-FUNNEL-01 rule A2 permits credential.NewTestHasher here because accesscoretest is test-support imported only by tests.

Types

type AccessFixture

type AccessFixture struct {

	// TenantID scopes all repo operations. Defaults to DefaultFixtureTenantID.
	TenantID tenant.TenantID
	// contains filtered or unexported fields
}

AccessFixture is the canonical in-memory test aggregate for accesscore. It wraps a single mem.Bundle so the four store-paired primitives (UserRepository, RoleRepository, SetupLock, TxRunner) all originate from the same underlying mem.Store — preventing the store-pairing footgun that caused PR #595.

In addition, the fixture holds singleton session.MemStore and refresh.Store instances so that test-built services (e.g. via BuildIdentityManageService) share session/refresh state with the credential invalidator. Round-2 review of PR #845 added this extension: the invalidator used to build its own independent session/refresh stores via internal/testutil, silently isolating it from any sessionlogin.Service that callers might later inject as TokenIssuer.

All tenant-scoped repo operations use DefaultFixtureTenantID unless the caller passes a specific tenant (#1337 PR-2).

Use NewAccessFixture to construct; never embed the zero value.

func BuildIdentityManageService

func BuildIdentityManageService(
	t *testing.T,
	opts ...BuildIdentityManageOption,
) (*identitymanage.Service, *AccessFixture, *outboxtest.Recorder)

BuildIdentityManageService constructs a ready-to-use *identitymanage.Service along with the AccessFixture and outboxtest.Recorder it is wired to.

Default wiring:

  • AccessFixture: fresh NewAccessFixture(t, clock.Real())
  • invalidator: NewCredentialInvalidator(t, WithInvalidatorFixture(fixture)) — shares the fixture's UserRepository + session.MemStore + refresh.Store so any sessionlogin.Service injected via WithIdentityTokenIssuer sees the same session/refresh state the invalidator revokes against
  • TxManager: fixture.TxRunner() (store-paired, full atomic semantics)
  • Emitter: outboxtest.NewRecorder()
  • Clock: clock.Real()
  • Logger: slog.New(slog.DiscardHandler)
  • TokenIssuer: failingTokenIssuer (t.Fatal on IssueForUser; replace via WithIdentityTokenIssuer for ChangePassword paths)
  • roleRepo: fixture.RoleRepository (required positional param) — wires the at-least-one-effective-admin guard

The returned fixture is the same one used internally — seed via fixture.SeedUser / SeedRole / SeedAssignment, then assert via fixture.GetUser / GetRole / UserRoles.

func NewAccessFixture

func NewAccessFixture(t *testing.T, clk clock.Clock) *AccessFixture

NewAccessFixture constructs an AccessFixture backed by mem.NewBundle(clk). session/refresh stores use the canonical test protocol that internal/testutil.RealSessionRepo / RealRefreshStore use so behavior matches existing in-package unit tests.

func (*AccessFixture) GetRole

func (f *AccessFixture) GetRole(ctx context.Context, id string) (SeededRoleView, error)

GetRole returns the seeded role as a SeededRoleView.

func (*AccessFixture) GetUser

func (f *AccessFixture) GetUser(ctx context.Context, id string) (SeededUserView, error)

GetUser returns the seeded user as a SeededUserView. External tests use this to assert that service operations updated user state without needing to import internal/domain.

func (*AccessFixture) SeedAssignment

func (f *AccessFixture) SeedAssignment(ctx context.Context, userID, roleID string) error

SeedAssignment assigns the role to the user via RoleRepository.AssignToUser. Returns an error if either the user or role does not exist in the store.

func (*AccessFixture) SeedRole

func (f *AccessFixture) SeedRole(ctx context.Context, r SeededRole) error

SeedRole persists role directly via the bundle-paired RoleRepository. AccessFixture does not expose the raw RoleRepository.

func (*AccessFixture) SeedUser

func (f *AccessFixture) SeedUser(ctx context.Context, u SeededUser) error

SeedUser persists u via domain.NewUser → UserRepository.Create. The resulting user is Active with AuthzEpoch=1; tests that need non-Active state must drive a service operation (Lock / Suspend / Delete) after seeding so the credential-event funnel runs.

func (*AccessFixture) TxRunner

func (f *AccessFixture) TxRunner() persistence.CellTxManager

TxRunner returns the bundle-paired store-bound CellTxManager. Use this when wiring services that require a real atomic TxManager (e.g. identitymanage Create → lock-scoped read-modify-write).

func (*AccessFixture) UserRoles

func (f *AccessFixture) UserRoles(ctx context.Context, userID string) ([]SeededRoleView, error)

UserRoles returns the roles currently assigned to the given user.

type BuildConfigReceiveOption

type BuildConfigReceiveOption func(*buildReceiveConfig)

BuildConfigReceiveOption configures BuildConfigReceiveService.

func WithConfigReceiveCollector

func WithConfigReceiveCollector(c obmetrics.ConfigEventCollector) BuildConfigReceiveOption

WithConfigReceiveCollector injects a config event collector so tests can observe ack / stale / permanent_error metric attribution downstream of HandleEntryUpserted / HandleEntryDeleted.

func WithConfigReceiveConfigGetter

func WithConfigReceiveConfigGetter(g *FakeConfigGetter) BuildConfigReceiveOption

WithConfigReceiveConfigGetter injects a FakeConfigGetter into the configreceive service so that GetEntry calls can be observed and stubbed in tests.

func WithConfigReceiveLogger

func WithConfigReceiveLogger(l *slog.Logger) BuildConfigReceiveOption

WithConfigReceiveLogger injects a logger. Defaults to slog.New(slog.DiscardHandler). See WithIdentityLogger for stderr-swap example.

type BuildIdentityManageOption

type BuildIdentityManageOption func(*buildIdentityConfig)

BuildIdentityManageOption configures BuildIdentityManageService.

func WithIdentityClock

func WithIdentityClock(clk clock.Clock) BuildIdentityManageOption

WithIdentityClock injects a clock (e.g. a test clock) into the identity service and its underlying invalidator. Defaults to clock.Real().

func WithIdentityFixture

func WithIdentityFixture(f *AccessFixture) BuildIdentityManageOption

WithIdentityFixture injects a pre-constructed (and possibly pre-seeded) AccessFixture into the builder. When not set, the builder creates a fresh one backed by clock.Real(). Use this option when you need to inspect or pre-seed state before calling BuildIdentityManageService.

func WithIdentityInvalidator

func WithIdentityInvalidator(inv *CredentialInvalidator) BuildIdentityManageOption

WithIdentityInvalidator overrides the credential invalidator. By default BuildIdentityManageService constructs an invalidator wired to the fixture (so user/session/refresh state stays paired). Use this escape hatch only when a test needs to substitute a custom invalidator built against the same fixture — passing nil keeps the default.

The parameter is the public opaque *CredentialInvalidator returned by NewCredentialInvalidator; the unwrap to internal/credentialinvalidate happens once inside BuildIdentityManageService, keeping the internal type unreachable from external test packages.

func WithIdentityLogger

func WithIdentityLogger(l *slog.Logger) BuildIdentityManageOption

WithIdentityLogger injects a logger. Defaults to slog.New(slog.DiscardHandler). To see verbose output during local debugging, swap in:

WithIdentityLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))

func WithIdentityTokenIssuer

func WithIdentityTokenIssuer(ti identitymanage.TokenIssuer) BuildIdentityManageOption

WithIdentityTokenIssuer injects a custom TokenIssuer. When not provided the default failingTokenIssuer is wired, which fails the test on IssueForUser call. Tests exercising ChangePassword must inject a real sessionlogin.Service. See cells/accesscore/slices/sessionlogin.Service which implements TokenIssuer.

type ConfigGetterStub

type ConfigGetterStub struct {
	// contains filtered or unexported fields
}

ConfigGetterStub is the sealed value type accepted by NewFakeConfigGetter. The fields are unexported; the only way to construct one is via the four typed constructors below. This makes self-contradictory states like "Sensitive=true with plaintext Value" unrepresentable in the type system — selecting the wrong stub semantic is selecting the wrong constructor name, surfaced at compile time.

AI Hard form: "typed function choice" — one API per semantic, no flag fields decoded at runtime.

func ErrorStub

func ErrorStub(err error) ConfigGetterStub

ErrorStub returns a stub that surfaces the supplied error from GetEntry. Use for transient or permanent error coverage; the error is returned to the caller verbatim (no wrapping).

func NotFoundStub

func NotFoundStub() ConfigGetterStub

NotFoundStub returns a stub representing a key that is explicitly absent; GetEntry returns ErrConfigRepoNotFound with CategoryDomain. Use this when stubbing stale-event paths (configcore reports the key was upserted but the subsequent fetch races a delete and finds nothing).

func PresentStub

func PresentStub(key, value string, version int) ConfigGetterStub

PresentStub returns a stub for a non-sensitive config key whose value is returned to callers via GetEntry. Use this for the common "key exists with plaintext value" path.

func SensitiveStub

func SensitiveStub(key string, version int) ConfigGetterStub

SensitiveStub returns a stub mirroring what configcore's internal HTTP getter returns for keys flagged Sensitive=true: Value is the redacted placeholder "******". This matches the production redaction contract documented in cells/accesscore/internal/ports/configport.go and lets tests assert the "reload triggers but plaintext never revealed" path without hard-coding the redaction sentinel in every test.

type CredentialInvalidator

type CredentialInvalidator struct {
	// contains filtered or unexported fields
}

CredentialInvalidator is the public opaque handle returned by NewCredentialInvalidator. It wraps the unexported *cells/accesscore/internal/credentialinvalidate.Invalidator so external _test packages can hold and pass the value without importing the internal/ package — which Go's internal-package rule forbids.

The struct intentionally has no exported fields and no exported methods: it is a sealed handle, not an API surface. The single legal touchpoint of the underlying internal type lives in this package (where the internal import is permitted), keeping the testutil → internal coupling unidirectional and unbypassable from outside cells/accesscore/.

Hard funnel form (sibling of Hard 范本 §"single sanctioned holder"):

  • upstream Hard — Go's internal-package rule makes *credentialinvalidate.Invalidator unnameable in any package not rooted at cells/accesscore/. External callers therefore cannot bypass this wrapper by declaring the raw type.
  • downstream Hard — unwrap field is unexported (.inv), so no external code can read or rewrap it; the only consumers are NewCredentialInvalidator (constructor) and BuildIdentityManageService (which threads the fixture-paired invalidator into identitymanage.NewService).

func NewCredentialInvalidator

func NewCredentialInvalidator(t *testing.T, opts ...CredentialInvalidatorOption) *CredentialInvalidator

NewCredentialInvalidator constructs an opaque *CredentialInvalidator wired to the user/session/refresh stores held by the supplied AccessFixture. The fixture is required: omitting WithInvalidatorFixture fails the test immediately so the invalidator can never silently run against an isolated store.

The returned *CredentialInvalidator is the only handle external test packages may hold. Pass it to WithIdentityInvalidator when building services; the internal unwrap to *credentialinvalidate.Invalidator happens inside this package.

type CredentialInvalidatorOption

type CredentialInvalidatorOption func(*invalidatorConfig)

CredentialInvalidatorOption configures NewCredentialInvalidator.

The only public option is WithInvalidatorFixture. Round-2 review of PR #845 (worktree 650) collapsed the previous WithInvalidatorUsers / WithInvalidatorSessions / WithInvalidatorRefresh trio because they let callers wire independent stores into the invalidator while a real sessionlogin.Service held its own state — the exact mis-pairing failure mode PR #595 was supposed to prevent. The fixture is now the sole source of the (UserRepository, session.Store, refresh.Store) triple; any other wiring path is unexpressible in the type system.

func WithInvalidatorFixture

func WithInvalidatorFixture(f *AccessFixture) CredentialInvalidatorOption

WithInvalidatorFixture is the required option for NewCredentialInvalidator. Passing nil is a no-op (the option function is idempotent); the final nil check happens inside NewCredentialInvalidator and fails the test.

Usage:

fix := accesscoretest.NewAccessFixture(t, clock.Real())
inv := accesscoretest.NewCredentialInvalidator(t,
    accesscoretest.WithInvalidatorFixture(fix),
)

type FakeConfigGetter

type FakeConfigGetter struct {
	// contains filtered or unexported fields
}

FakeConfigGetter is a stub implementation of ports.ConfigGetter for use in tests. It is concurrency-safe. Unknown keys (not in the stubs map) return ErrConfigRepoNotFound by convention so test authors do not need to explicitly stub every key they do not care about.

Usage:

g := accesscoretest.NewFakeConfigGetter(map[string]accesscoretest.ConfigGetterStub{
    "jwt.ttl":   accesscoretest.PresentStub("jwt.ttl", "3600", 1),
    "kms.key":   accesscoretest.SensitiveStub("kms.key", 4),
    "stale.key": accesscoretest.NotFoundStub(),
    "broken":    accesscoretest.ErrorStub(myTransientErr),
})

func NewFakeConfigGetter

func NewFakeConfigGetter(stubs map[string]ConfigGetterStub) *FakeConfigGetter

NewFakeConfigGetter constructs a FakeConfigGetter with a deep copy of the supplied stubs map. Subsequent mutations of the caller's map (or of any ConfigGetterStub value re-used as a map alias) do not affect the fake. A nil or empty stubs map means all keys return ErrConfigRepoNotFound.

func (*FakeConfigGetter) Calls

func (g *FakeConfigGetter) Calls() []string

Calls returns the ordered list of keys passed to GetEntry since the last Reset. Returns a copy; mutating the slice does not affect the recorder.

func (*FakeConfigGetter) GetEntry

GetEntry implements ports.ConfigGetter. It honors ctx.Err() (matching the production HTTP getter's cancellation semantic), records the call (key and tenant), and returns the stubbed response for the given key. Unknown keys and NotFoundStub entries return ErrConfigRepoNotFound with CategoryDomain.

func (*FakeConfigGetter) Reset

func (g *FakeConfigGetter) Reset()

Reset clears the call log. Stubs are not modified.

func (*FakeConfigGetter) TenantCalls

func (g *FakeConfigGetter) TenantCalls() []tenant.TenantID

TenantCalls returns the ordered list of tenant.TenantID values passed to GetEntry since the last Reset. The i-th element corresponds to the i-th entry in Calls(). Returns a copy.

type SeededRole

type SeededRole struct {
	ID   string
	Name string
}

SeededRole is the value-type input for AccessFixture.SeedRole. Permissions are not modeled here — current journeys do not seed them; add a SeededPermission type when needed.

type SeededRoleView

type SeededRoleView struct {
	ID   string
	Name string
}

SeededRoleView is the value-type output returned by AccessFixture.GetRole / AccessFixture.UserRoles.

type SeededUser

type SeededUser struct {
	ID           string
	Username     string
	Email        string
	PasswordHash string // bcrypt-formatted; tests can use "$2a$04$dummy"
}

SeededUser is the value-type input for AccessFixture.SeedUser. It mirrors the public field set of internal/domain.User that test authors need at seed time — all four fields are required.

Active status and AuthzEpoch=1 are implied (domain.NewUser defaults). Non-Active state must come from a service operation invoked after seeding.

type SeededUserView

type SeededUserView struct {
	ID           string
	Username     string
	Email        string
	PasswordHash string
	Status       UserStatus
	AuthzEpoch   int64
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

SeededUserView is the value-type output returned by AccessFixture.GetUser. It surfaces the seed fields plus rehydrated authz/lifecycle state so test assertions don't need to import internal/domain.

type UserStatus

type UserStatus string

UserStatus is the public mirror of domain.UserStatus used by SeededUserView so external test packages do not need to import internal/domain to read user state. SeedUser always produces Active users; tests that need Locked/Suspended state must drive a service operation (Lock / Suspend) after seeding so the credential-event funnel runs correctly.

const (
	UserStatusActive    UserStatus = "active"
	UserStatusSuspended UserStatus = "suspended"
	UserStatusLocked    UserStatus = "locked"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL