go-loose/internal/store/integration_test.go

522 lines
19 KiB
Go
Raw Normal View History

2026-10-04 12:24:13 +02:00
package store_test
import (
"context"
"database/sql"
"errors"
"os"
"strings"
"testing"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// integrationStore opens the disposable PostgreSQL database named by
// GO_LOOSE_TEST_DATABASE_URL, drops every table, and applies all migrations. The
// test is skipped when the variable is absent so unit runs never touch a
// developer's data.
func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
t.Helper()
url := os.Getenv("GO_LOOSE_TEST_DATABASE_URL")
if url == "" {
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
ctx := context.Background()
db, err := database.Open(ctx, url)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := resetSchema(ctx, db); err != nil {
t.Fatalf("reset schema: %v", err)
}
if err := database.Migrate(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.New(db), db, ctx
}
// grantApplicationAccess inserts an application grant directly so that tests can
// build states the management API intentionally refuses to create, such as a
// grant for a user with no membership.
func grantApplicationAccess(t *testing.T, db *sql.DB, ctx context.Context, applicationID, userID string) {
t.Helper()
if _, err := db.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
VALUES ($1, $2, $2)`, applicationID, userID); err != nil {
t.Fatalf("grant application access: %v", err)
}
}
func contractDocument() contract.Document {
return contract.Document{
Version: "3.1.0",
JSON: []byte(`{"openapi":"3.1.0","paths":{"/health":{"get":{"operationId":"health"}}}}`),
Endpoints: []contract.Endpoint{{Method: "GET", Path: "/health", OperationID: "health"}},
}
}
// resetSchema drops every table in the public schema, including
// schema_migrations, so that Migrate replays the full history from scratch.
func resetSchema(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `
SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
return err
}
defer rows.Close()
tables := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
tables = append(tables, `"`+name+`"`)
}
if err := rows.Err(); err != nil {
return err
}
for _, name := range tables {
if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+name+" CASCADE"); err != nil {
return err
}
}
return nil
}
// newUser creates or reuses an account and returns its id.
func newUser(t *testing.T, s *store.Store, ctx context.Context, email string) string {
t.Helper()
user, err := s.UpsertUser(ctx, "test:"+email, email, strings.ToUpper(email[:1])+email[1:])
if err != nil {
t.Fatalf("upsert %s: %v", email, err)
}
return user.ID
}
// newTenant creates a tenant owned by owner and registers one application.
func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug string) store.Tenant {
t.Helper()
tenant, err := s.CreateTenant(ctx, owner, slug, strings.ToUpper(slug))
if err != nil {
t.Fatalf("create tenant %s: %v", slug, err)
}
return tenant
}
2026-10-04 16:57:30 +02:00
// addMember invites a member into the tenant with the given built-in role.
2026-10-04 12:24:13 +02:00
func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string {
t.Helper()
2026-10-04 16:57:30 +02:00
roleID, err := s.SystemRoleID(ctx, membershipRole)
if err != nil {
t.Fatalf("look up role %q: %v", membershipRole, err)
}
member, err := s.InviteUser(ctx, owner, tenantID, email, email, roleID, "not-a-real-hash")
2026-10-04 12:24:13 +02:00
if err != nil {
t.Fatalf("invite %s as %s: %v", email, membershipRole, err)
}
if member.Role != membershipRole {
t.Fatalf("invited role = %q, want %q", member.Role, membershipRole)
}
return member.ID
}
func TestMigrationsApplyAndRolesAreAccepted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
2026-10-04 16:57:30 +02:00
for _, item := range []string{"developer", "operator", "user"} {
addMember(t, s, ctx, owner, tenant.ID, item+"@example.test", item)
2026-10-04 12:24:13 +02:00
}
if _, err := s.ListTenants(ctx, owner); err != nil {
t.Fatalf("list tenants: %v", err)
}
}
2026-10-04 16:57:30 +02:00
func TestUnknownRoleIDIsRefused(t *testing.T) {
2026-10-04 12:24:13 +02:00
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
2026-10-04 16:57:30 +02:00
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "00000000-0000-0000-0000-000000000000", nil); err == nil {
t.Fatal("assigning a role that does not exist must be refused")
2026-10-04 12:24:13 +02:00
}
}
func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("owner cannot create an application: %v", err)
}
tests := []struct {
2026-10-04 16:57:30 +02:00
role string
2026-10-04 12:24:13 +02:00
canCreateApp bool
canIssueKey bool
canSaveContract bool
canListManaged bool
canConfigureClien bool
}{
2026-10-04 16:57:30 +02:00
// owner and admin administer members; developer and operator may also
// maintain their own tenant's members, which is why they can list them.
{role: "owner", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "admin", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "developer", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "operator", canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: false},
{role: "viewer", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
{role: "user", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
2026-10-04 12:24:13 +02:00
}
for _, test := range tests {
2026-10-04 16:57:30 +02:00
t.Run(test.role, func(t *testing.T) {
member := addMember(t, s, ctx, owner, tenant.ID, test.role+"@example.test", test.role)
slug := strings.ReplaceAll(test.role, "owner", "own")
2026-10-04 12:24:13 +02:00
_, err := s.CreateApplication(ctx, member, store.Application{
TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{},
})
assertPermission(t, "create application", err, test.canCreateApp)
_, err = s.CreateAPIKey(ctx, member, application.ID, slug+"-key", "gl_prefix", secretHash(slug), nil)
assertPermission(t, "issue key", err, test.canIssueKey)
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
assertPermission(t, "import contract", err, test.canSaveContract)
managed, err := s.ListManagedUsers(ctx, member)
if err != nil {
t.Fatalf("list managed users: %v", err)
}
if test.canListManaged != (len(managed) > 0) {
t.Fatalf("users.read for %s: got %d members, want non-empty=%v", test.role, len(managed), test.canListManaged)
}
_, err = s.ConfigureClient(ctx, member, application.ID, []string{"https://app.example.test/callback"}, secretHash(slug))
assertPermission(t, "configure client login", err, test.canConfigureClien)
})
}
}
func TestConsoleAccessExcludesSelfServiceMembers(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
2026-10-04 16:57:30 +02:00
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", "user")
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", "operator")
2026-10-04 12:24:13 +02:00
waiting := newUser(t, s, ctx, "waiting@example.test")
for _, test := range []struct {
name string
userID string
console bool
member bool
}{
{name: "owner", userID: owner, console: true, member: true},
{name: "operator", userID: operator, console: true, member: true},
{name: "self-service user", userID: selfService, console: false, member: true},
{name: "waiting room", userID: waiting, console: false, member: false},
} {
t.Run(test.name, func(t *testing.T) {
console, err := s.HasConsoleAccess(ctx, test.userID)
if err != nil {
t.Fatalf("HasConsoleAccess: %v", err)
}
if console != test.console {
t.Fatalf("HasConsoleAccess = %v, want %v", console, test.console)
}
member, err := s.HasMembership(ctx, test.userID)
if err != nil {
t.Fatalf("HasMembership: %v", err)
}
if member != test.member {
t.Fatalf("HasMembership = %v, want %v", member, test.member)
}
})
}
}
func TestTenantPermissionsComeFromTheRoleMatrix(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
2026-10-04 16:57:30 +02:00
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", "developer")
2026-10-04 12:24:13 +02:00
tenants, err := s.ListTenants(ctx, developer)
if err != nil {
t.Fatalf("list tenants: %v", err)
}
2026-10-04 16:57:30 +02:00
if len(tenants) != 1 || tenants[0].Role != "developer" {
2026-10-04 12:24:13 +02:00
t.Fatalf("tenants = %+v", tenants)
}
if !contains(tenants[0].Permissions, string(role.ManageApplications)) {
t.Fatalf("developer permissions = %v", tenants[0].Permissions)
}
2026-10-04 16:57:30 +02:00
// developer administers members but must not mint roles, which is what
// defines privilege in the first place.
if !contains(tenants[0].Permissions, string(role.ManageUsers)) {
t.Fatalf("developer permissions = %v, want users.manage", tenants[0].Permissions)
}
if contains(tenants[0].Permissions, string(role.ManageRoles)) {
t.Fatalf("developer permissions = %v, must not manage roles", tenants[0].Permissions)
2026-10-04 12:24:13 +02:00
}
}
func TestProfileRoundTrip(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
2026-10-04 16:57:30 +02:00
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
2026-10-04 12:24:13 +02:00
before, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if before.AvatarKey != nil || before.NameCustomized {
t.Fatalf("fresh profile = %+v", before)
}
2026-10-04 16:57:30 +02:00
if len(before.Memberships) != 1 || before.Memberships[0].Role != "operator" {
2026-10-04 12:24:13 +02:00
t.Fatalf("memberships = %+v", before.Memberships)
}
if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) {
t.Fatalf("membership permissions = %v", before.Memberships[0].Permissions)
}
name := "Renamed By User"
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{DisplayName: &name}); err != nil {
t.Fatalf("rename: %v", err)
}
after, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile after rename: %v", err)
}
if after.DisplayName != name || !after.NameCustomized || after.ProfileUpdatedAt == nil {
t.Fatalf("profile = %+v", after)
}
}
func TestLoginDoesNotOverwriteACustomizedDisplayName(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
name := "My Chosen Name"
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{DisplayName: &name}); err != nil {
t.Fatalf("rename: %v", err)
}
for range 2 {
if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
}
profile, err := s.Profile(ctx, user)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.DisplayName != name {
t.Fatalf("display name = %q, want %q", profile.DisplayName, name)
}
}
func TestUncustomizedDisplayNameStillFollowsTheIdentityProvider(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
profile, err := s.Profile(ctx, user)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.DisplayName != "Identity Provider Name" {
t.Fatalf("display name = %q", profile.DisplayName)
}
}
func TestPictureIsStoredAndReadableByKey(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
2026-10-04 16:57:30 +02:00
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
2026-10-04 12:24:13 +02:00
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{
AvatarKey: key, AvatarData: payload, ContentType: "image/png",
}); err != nil {
t.Fatalf("store picture: %v", err)
}
picture, err := s.Picture(ctx, key)
if err != nil {
t.Fatalf("load picture: %v", err)
}
if picture.ContentType != "image/png" || string(picture.Data) != string(payload) {
t.Fatalf("picture = %+v", picture)
}
if picture.UpdatedAt.IsZero() {
t.Fatal("picture must record when it was stored")
}
profile, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.AvatarKey == nil || *profile.AvatarKey != key || profile.AvatarUpdatedAt == nil {
t.Fatalf("profile avatar = %+v", profile)
}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{RemoveAvatar: true}); err != nil {
t.Fatalf("remove picture: %v", err)
}
if _, err := s.Picture(ctx, key); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Picture() error = %v, want ErrNotFound", err)
}
cleared, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if cleared.AvatarKey != nil || cleared.AvatarUpdatedAt != nil {
t.Fatalf("profile still advertises a picture: %+v", cleared)
}
}
func TestReplacingAPictureRetiresTheOldKey(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
first := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
second := "b3JkZXJ5a2V5Zm9ydGhlU2Vjb25kVXBvZGF0ZVRlc3QxMjM0NQ"
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: first, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil {
t.Fatalf("store first picture: %v", err)
}
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: second, AvatarData: []byte("b"), ContentType: "image/png"}); err != nil {
t.Fatalf("store second picture: %v", err)
}
if _, err := s.Picture(ctx, first); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("the replaced key must stop resolving, got %v", err)
}
picture, err := s.Picture(ctx, second)
if err != nil || string(picture.Data) != "b" {
t.Fatalf("picture = %+v, err = %v", picture, err)
}
}
func TestPictureKeysAreUnique(t *testing.T) {
s, _, ctx := integrationStore(t)
first, second := newUser(t, s, ctx, "one@example.test"), newUser(t, s, ctx, "two@example.test")
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
if err := s.UpdateProfile(ctx, first, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil {
t.Fatalf("store first picture: %v", err)
}
if err := s.UpdateProfile(ctx, second, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("b"), ContentType: "image/png"}); err == nil {
t.Fatal("two users must not be able to claim the same picture key")
}
}
func TestIncompletePictureIsRejectedByTheDatabase(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"})
if err != nil {
t.Fatalf("an update without picture bytes is a no-op, got %v", err)
}
if _, err := s.Picture(ctx, "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Picture() error = %v, want ErrNotFound", err)
}
}
func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
2026-10-04 16:57:30 +02:00
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
2026-10-04 12:24:13 +02:00
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{
AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl",
AvatarData: []byte("a"), ContentType: "image/png",
}); err != nil {
t.Fatalf("store picture: %v", err)
}
grantApplicationAccess(t, db, ctx, application.ID, member)
if err := s.CreateAuthorizationCode(ctx, application.ID, member, "https://app.example.test/callback", []byte("code-hash")); err != nil {
t.Fatalf("create authorization code: %v", err)
}
user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/callback", []byte("hash"), []byte("code-hash"), []byte("token-hash"))
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
2026-10-04 16:57:30 +02:00
if user.Role != "operator" {
2026-10-04 12:24:13 +02:00
t.Fatalf("role = %q, want operator", user.Role)
}
if user.AvatarKey == nil {
t.Fatal("the client payload must expose the picture locator")
}
byToken, err := s.ClientUserByToken(ctx, []byte("token-hash"))
if err != nil {
t.Fatalf("client user by token: %v", err)
}
2026-10-04 16:57:30 +02:00
if byToken.Role != "operator" || byToken.AvatarKey == nil {
2026-10-04 12:24:13 +02:00
t.Fatalf("client user = %+v", byToken)
}
}
func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
orphan := newUser(t, s, ctx, "orphan@example.test")
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/cb"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
grantApplicationAccess(t, db, ctx, application.ID, orphan)
if err := s.CreateAuthorizationCode(ctx, application.ID, orphan, "https://app.example.test/cb", []byte("code")); err != nil {
t.Fatalf("create authorization code: %v", err)
}
user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/cb", []byte("hash"), []byte("code"), []byte("token"))
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
2026-10-04 16:57:30 +02:00
if user.Role != "user" {
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, "user")
2026-10-04 12:24:13 +02:00
}
}
func assertPermission(t *testing.T, operation string, err error, allowed bool) {
t.Helper()
switch {
case allowed && err != nil:
t.Fatalf("%s must be allowed, got %v", operation, err)
case !allowed && !errors.Is(err, store.ErrNotFound):
t.Fatalf("%s must be denied with ErrNotFound, got %v", operation, err)
}
}
// secretHash builds a distinct digest per key name because secret_hash is unique.
func secretHash(name string) []byte {
return []byte("sha256-of-" + name)
}
func contains(values []string, want string) bool {
for _, item := range values {
if item == want {
return true
}
}
return false
}