mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:51:30 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
250 lines
10 KiB
Go
250 lines
10 KiB
Go
// Integration tests for the U-3 schema-vs-seed coupling fix.
|
|
//
|
|
// Pre-U-3 the deploy compose stack mounted both a hand-curated subset of
|
|
// `migrations/*.up.sql` and `seed.sql` into postgres
|
|
// `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time.
|
|
// When `seed.sql` was updated to reference columns added by migrations
|
|
// *after* the mounted cutoff (e.g., `policy_rules.severity` from
|
|
// `000013_policy_rule_severity.up.sql`), initdb crashed during the seed
|
|
// step and the container was reported `unhealthy` indefinitely.
|
|
//
|
|
// Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
|
|
// internal/repository/postgres.RunMigrations + RunSeed. These tests pin
|
|
// that contract: RunSeed must complete without error against a freshly
|
|
// migrated database, and re-application must be idempotent so server
|
|
// restarts don't double-insert.
|
|
//
|
|
// Skipped under -short to keep CI fast lanes green; the integration lane
|
|
// runs them via the testcontainers harness.
|
|
package postgres_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"github.com/certctl-io/certctl/internal/repository/postgres"
|
|
)
|
|
|
|
// TestRunSeed_AppliesIdempotently verifies the U-3 contract that RunSeed
|
|
// can be called repeatedly against a populated database without error and
|
|
// without producing duplicate rows. The server invokes RunSeed on EVERY
|
|
// boot (it has no migration-state table to skip from), so any non-
|
|
// idempotent INSERT in seed.sql would crash the container loop on the
|
|
// second start.
|
|
//
|
|
// The assertion uses renewal_policies.id='rp-default' as a witness — that
|
|
// row is the most-referenced FK target in the seed (it's the default
|
|
// renewal policy attached to every certificate that doesn't override).
|
|
// If the seed double-inserted, we'd see SQLSTATE 23505 from the second
|
|
// RunSeed call. If the seed silently ON CONFLICT-DO-NOTHING'd as
|
|
// designed, the row count stays at exactly 1.
|
|
func TestRunSeed_AppliesIdempotently(t *testing.T) {
|
|
tdb := getTestDB(t)
|
|
db := tdb.freshSchema(t)
|
|
ctx := context.Background()
|
|
|
|
migrationsPath := findMigrationsDir()
|
|
|
|
// Apply the seed twice — second call simulates a server restart on a
|
|
// populated database. Both must succeed; pre-U-3 the second call
|
|
// would fail with 23505 if any INSERT lacked ON CONFLICT.
|
|
if err := postgres.RunSeed(db, migrationsPath); err != nil {
|
|
t.Fatalf("RunSeed (first call) returned error: %v", err)
|
|
}
|
|
if err := postgres.RunSeed(db, migrationsPath); err != nil {
|
|
t.Fatalf("RunSeed (second call — idempotency check) returned error: %v\n"+
|
|
"This means the seed produced a duplicate row; every INSERT in seed.sql "+
|
|
"must use ON CONFLICT (id) DO NOTHING because the server applies the "+
|
|
"seed on EVERY start.", err)
|
|
}
|
|
|
|
// Witness check: rp-default is the renewal policy every cert defaults
|
|
// to. Exactly one row must exist after two seed applications.
|
|
var count int
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM renewal_policies WHERE id = 'rp-default'`,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("witness query failed: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("renewal_policies WHERE id='rp-default' returned %d rows after two RunSeed calls; want exactly 1 (ON CONFLICT idempotency contract)", count)
|
|
}
|
|
}
|
|
|
|
// TestRunSeed_MissingFileIsNoOp verifies the fail-soft contract documented
|
|
// on RunSeed: an operator who deletes seed.sql for custom packaging (CI
|
|
// pipelines that bake their own seeds, cert-manager managed deployments)
|
|
// must still get a healthy server boot. RunSeed returning nil for a
|
|
// missing file is the only way to hold this contract — returning an error
|
|
// would force every minimal-image deployment to ship the seed file just
|
|
// to satisfy a no-op load.
|
|
//
|
|
// We point at a directory that exists (empty temp dir) but contains no
|
|
// seed.sql. RunSeed must return nil silently.
|
|
func TestRunSeed_MissingFileIsNoOp(t *testing.T) {
|
|
// Q-1 closure (cat-s3-58ce7e9840be): RunSeed opens a *sql.DB connection
|
|
// against the live PostgreSQL testcontainer. Run with:
|
|
// go test -count=1 ./internal/repository/postgres/... (omit -short)
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
// Use a brand-new empty directory so seed.sql is unambiguously absent.
|
|
emptyDir := t.TempDir()
|
|
|
|
// Pass a nil *sql.DB on purpose — RunSeed must short-circuit on the
|
|
// missing file BEFORE touching the DB. If the implementation ever
|
|
// regresses and tries to db.Exec(string(content)) with nil content,
|
|
// this will surface as a nil-deref instead of a silent corruption.
|
|
var db *sql.DB
|
|
if err := postgres.RunSeed(db, emptyDir); err != nil {
|
|
t.Fatalf("RunSeed against an empty directory should return nil; got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunDemoSeed_AppliesIdempotently mirrors the RunSeed idempotency
|
|
// contract for the demo overlay. The compose demo stack
|
|
// (deploy/docker-compose.demo.yml) sets CERTCTL_DEMO_SEED=true; the
|
|
// server applies seed_demo.sql at every boot. Same constraint as the
|
|
// baseline seed: if any INSERT lacks ON CONFLICT, the server will
|
|
// crash-loop on restart.
|
|
//
|
|
// Witness: seed_demo.sql inserts t-platform into the teams table at line
|
|
// 11. That row is referenced by every demo-team-owned certificate, so
|
|
// duplicate-insertion would block the entire demo on restart.
|
|
func TestRunDemoSeed_AppliesIdempotently(t *testing.T) {
|
|
tdb := getTestDB(t)
|
|
db := tdb.freshSchema(t)
|
|
ctx := context.Background()
|
|
|
|
migrationsPath := findMigrationsDir()
|
|
|
|
// Order matters — RunSeed must run first so the FK targets the demo
|
|
// seed depends on (rp-* renewal policies, etc.) exist before the
|
|
// demo INSERTs run. This mirrors the order in cmd/server/main.go.
|
|
if err := postgres.RunSeed(db, migrationsPath); err != nil {
|
|
t.Fatalf("RunSeed prerequisite failed: %v", err)
|
|
}
|
|
|
|
if err := postgres.RunDemoSeed(db, migrationsPath); err != nil {
|
|
t.Fatalf("RunDemoSeed (first call) returned error: %v", err)
|
|
}
|
|
if err := postgres.RunDemoSeed(db, migrationsPath); err != nil {
|
|
t.Fatalf("RunDemoSeed (second call — idempotency check) returned error: %v", err)
|
|
}
|
|
|
|
var count int
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM teams WHERE id = 't-platform'`,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("witness query failed: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("teams WHERE id='t-platform' returned %d rows after two RunDemoSeed calls; want exactly 1", count)
|
|
}
|
|
}
|
|
|
|
// TestMigration000017_RetryIntervalRename verifies the U-3 ride-along
|
|
// column rename: renewal_policies.retry_interval_minutes →
|
|
// retry_interval_seconds (cat-o-retry_interval_unit_mismatch). The unit
|
|
// was always seconds in practice — the column name lied. Migration 000017
|
|
// renames the column with a DO $$ guard so re-application is safe.
|
|
//
|
|
// After all migrations have been applied (which the test harness does in
|
|
// freshSchema), the new column must exist and the old column must NOT.
|
|
// information_schema.columns is the source of truth for both checks.
|
|
func TestMigration000017_RetryIntervalRename(t *testing.T) {
|
|
tdb := getTestDB(t)
|
|
db := tdb.freshSchema(t)
|
|
ctx := context.Background()
|
|
|
|
// Helper — true iff the named column exists on renewal_policies.
|
|
hasColumn := func(name string) bool {
|
|
t.Helper()
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM information_schema.columns
|
|
WHERE table_name = 'renewal_policies' AND column_name = $1
|
|
`, name).Scan(&n)
|
|
if err != nil {
|
|
t.Fatalf("information_schema query for column %q failed: %v", name, err)
|
|
}
|
|
return n > 0
|
|
}
|
|
|
|
if !hasColumn("retry_interval_seconds") {
|
|
t.Error("renewal_policies.retry_interval_seconds is missing — migration 000017 did not apply, or it was applied before the rename block")
|
|
}
|
|
if hasColumn("retry_interval_minutes") {
|
|
t.Error("renewal_policies.retry_interval_minutes still exists — the rename in migration 000017 must drop the old name (cat-o-retry_interval_unit_mismatch)")
|
|
}
|
|
}
|
|
|
|
// TestMigration000017_NotificationCreatedAt verifies the U-3 ride-along
|
|
// column add: notification_events.created_at NOT NULL DEFAULT NOW()
|
|
// (cat-o-notification_created_at_dead_field). Pre-U-3 the Go domain had
|
|
// the field but the DB lacked the column, so the JSON API serialised
|
|
// 0001-01-01.
|
|
func TestMigration000017_NotificationCreatedAt(t *testing.T) {
|
|
tdb := getTestDB(t)
|
|
db := tdb.freshSchema(t)
|
|
ctx := context.Background()
|
|
|
|
var dataType, isNullable, columnDefault sql.NullString
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT data_type, is_nullable, column_default
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'notification_events' AND column_name = 'created_at'
|
|
`).Scan(&dataType, &isNullable, &columnDefault)
|
|
if err != nil {
|
|
t.Fatalf("information_schema query for created_at failed: %v\n"+
|
|
"Migration 000017 should have added notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW().", err)
|
|
}
|
|
|
|
if dataType.String != "timestamp with time zone" {
|
|
t.Errorf("notification_events.created_at data_type = %q, want %q",
|
|
dataType.String, "timestamp with time zone")
|
|
}
|
|
if isNullable.String != "NO" {
|
|
t.Errorf("notification_events.created_at is_nullable = %q, want NO (the column must be NOT NULL so legacy rows get the DEFAULT)",
|
|
isNullable.String)
|
|
}
|
|
if columnDefault.String == "" {
|
|
t.Error("notification_events.created_at has no DEFAULT — legacy rows added before migration 000017 would fail the NOT NULL gate without one")
|
|
}
|
|
}
|
|
|
|
// TestMigration000017_HealthCheckOrphansDropped verifies the U-3
|
|
// ride-along column drop: network_scan_targets lost the orphan
|
|
// health_check_enabled / health_check_interval_seconds columns
|
|
// (cat-o-health_check_column_orphans). These were declared by an early
|
|
// migration but never wired into Go code — schema noise that confused
|
|
// operators reading raw SQL. Migration 000017 drops them.
|
|
func TestMigration000017_HealthCheckOrphansDropped(t *testing.T) {
|
|
tdb := getTestDB(t)
|
|
db := tdb.freshSchema(t)
|
|
ctx := context.Background()
|
|
|
|
hasColumn := func(name string) bool {
|
|
t.Helper()
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM information_schema.columns
|
|
WHERE table_name = 'network_scan_targets' AND column_name = $1
|
|
`, name).Scan(&n)
|
|
if err != nil {
|
|
t.Fatalf("information_schema query for column %q failed: %v", name, err)
|
|
}
|
|
return n > 0
|
|
}
|
|
|
|
for _, col := range []string{"health_check_enabled", "health_check_interval_seconds"} {
|
|
if hasColumn(col) {
|
|
t.Errorf("network_scan_targets.%s still exists — migration 000017 must drop it (cat-o-health_check_column_orphans)", col)
|
|
}
|
|
}
|
|
}
|