mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).
- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
parameter, JSON null arrays, expires_at timezone casts, passkey list
GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
166 lines
5.3 KiB
Go
166 lines
5.3 KiB
Go
package lookup
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
ddlpkg "github.com/bitechdev/ResolveSpec/pkg/security/lookup/ddl"
|
|
)
|
|
|
|
// TestDefaultSchemaMatchesSQLiteDDL keeps the default schema in step with the reference DDL:
|
|
// every table and column in the DDL must be a known logical column and vice versa.
|
|
func TestDefaultSchemaMatchesSQLiteDDL(t *testing.T) {
|
|
ddl, err := ddlpkg.SQL("sqlite")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tableRe := regexp.MustCompile(`(?s)CREATE TABLE IF NOT EXISTS (\w+) \((.*?)\n\);`)
|
|
colRe := regexp.MustCompile(`^\s*(\w+)\s+[A-Z]+`)
|
|
def := DefaultSchema()
|
|
seen := map[Entity]bool{}
|
|
for _, m := range tableRe.FindAllStringSubmatch(string(ddl), -1) {
|
|
e := Entity(m[1])
|
|
tbl, ok := def[e]
|
|
if !ok {
|
|
t.Errorf("DDL table %s has no entity", e)
|
|
continue
|
|
}
|
|
seen[e] = true
|
|
cols := map[string]bool{}
|
|
for _, line := range strings.Split(m[2], "\n") {
|
|
if cm := colRe.FindStringSubmatch(line); cm != nil && cm[1] != "PRIMARY" && cm[1] != "CHECK" && cm[1] != "FOREIGN" {
|
|
cols[cm[1]] = true
|
|
if _, ok := tbl.Columns[cm[1]]; !ok {
|
|
t.Errorf("DDL column %s.%s is not a logical column", e, cm[1])
|
|
}
|
|
}
|
|
}
|
|
for c := range tbl.Columns {
|
|
if !cols[c] {
|
|
t.Errorf("logical column %s.%s is not in the DDL", e, c)
|
|
}
|
|
}
|
|
}
|
|
for e := range def {
|
|
if !seen[e] {
|
|
t.Errorf("entity %s is not in the DDL", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDefaultSchemaValid(t *testing.T) {
|
|
if err := DefaultSchema().Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := DefaultProcNames().Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaMergeAndLookup(t *testing.T) {
|
|
cfg := Config{Schema: Schema{
|
|
EntityUsers: {Name: "app_users", Schema: "auth", Columns: map[string]string{"username": "login_name"}},
|
|
}}
|
|
r, err := cfg.Resolve()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := r.Schema.TableName(EntityUsers); got != "app_users" {
|
|
t.Errorf("table = %q", got)
|
|
}
|
|
if got := r.Schema.SchemaName(EntityUsers); got != "auth" {
|
|
t.Errorf("schema = %q", got)
|
|
}
|
|
if got := r.Schema.Col(UsersUsername); got != "login_name" {
|
|
t.Errorf("username col = %q", got)
|
|
}
|
|
if got := r.Schema.Col(UsersEmail); got != "email" {
|
|
t.Errorf("email col = %q, want default", got)
|
|
}
|
|
// Merge must not mutate the defaults.
|
|
if DefaultSchema().Col(UsersUsername) != "username" {
|
|
t.Error("default schema was mutated")
|
|
}
|
|
}
|
|
|
|
func TestSchemaZeroValueUsesDefaults(t *testing.T) {
|
|
var s Schema
|
|
if s.TableName(EntityUserKeys) != "user_keys" || s.Col(KeysKeyHash) != "key_hash" {
|
|
t.Error("zero schema should fall back to defaults")
|
|
}
|
|
}
|
|
|
|
func TestResolveRejectsBadConfig(t *testing.T) {
|
|
bad := map[string]Config{
|
|
"table injection": {Schema: Schema{EntityUsers: {Name: "users; DROP TABLE users"}}},
|
|
"column injection": {Schema: Schema{EntityUsers: {Columns: map[string]string{"id": "id) --"}}}},
|
|
"schema injection": {Schema: Schema{EntityUsers: {Schema: "a.b"}}},
|
|
"unknown entity": {Schema: Schema{"nope": {Name: "x"}}},
|
|
"unknown column": {Schema: Schema{EntityUsers: {Columns: map[string]string{"nope": "x"}}}},
|
|
"proc injection": {Procs: ProcNames{Login: "f(); --"}},
|
|
"bad mode": {Mode: "sometimes"},
|
|
"bad override": {Overrides: map[Op]Mode{OpLogin: "x"}},
|
|
"bad dialect": {Dialect: "oracle"},
|
|
}
|
|
for name, cfg := range bad {
|
|
if _, err := cfg.Resolve(); err == nil {
|
|
t.Errorf("%s: expected error", name)
|
|
}
|
|
}
|
|
// A single schema qualifier is allowed on table and procedure names.
|
|
ok := Config{
|
|
Schema: Schema{EntityUsers: {Name: "auth.users"}},
|
|
Procs: ProcNames{Login: "auth.resolvespec_login"},
|
|
}
|
|
if _, err := ok.Resolve(); err != nil {
|
|
t.Errorf("qualified names should be valid: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcNamesMerge(t *testing.T) {
|
|
m := DefaultProcNames().Merge(ProcNames{Login: "custom_login"})
|
|
if m.Login != "custom_login" {
|
|
t.Errorf("Login = %q", m.Login)
|
|
}
|
|
if m.Register != "resolvespec_register" {
|
|
t.Errorf("Register = %q, want default", m.Register)
|
|
}
|
|
if DefaultProcNames().LoginAPIKey != "resolvespec_login_api_key" {
|
|
t.Error("LoginAPIKey default missing")
|
|
}
|
|
if DefaultProcNames().KeystoreValidateKey != "resolvespec_keystore_validate_key" {
|
|
t.Error("keystore defaults missing")
|
|
}
|
|
}
|
|
|
|
func TestEffectiveMode(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg Config
|
|
dialect string
|
|
want Mode
|
|
wantErr bool
|
|
}{
|
|
{"pg default", Config{}, DialectPostgres, ModeProcedure, false},
|
|
{"sqlite default", Config{}, DialectSQLite, ModeDirect, false},
|
|
{"mysql default", Config{}, DialectMySQL, ModeDirect, false},
|
|
{"pg direct", Config{Mode: ModeDirect}, DialectPostgres, ModeDirect, false},
|
|
{"pg auto probes", Config{Mode: ModeAuto}, DialectPostgres, ModeAuto, false},
|
|
{"sqlite auto is direct", Config{Mode: ModeAuto}, DialectSQLite, ModeDirect, false},
|
|
{"sqlite procedure rejected", Config{Mode: ModeProcedure}, DialectSQLite, "", true},
|
|
{"override wins", Config{Overrides: map[Op]Mode{OpSession: ModeDirect}}, DialectPostgres, ModeDirect, false},
|
|
{"override only for its op", Config{Overrides: map[Op]Mode{OpSession: ModeDirect}}, DialectPostgres, ModeProcedure, false},
|
|
}
|
|
for _, c := range cases {
|
|
op := OpLogin
|
|
if strings.HasPrefix(c.name, "override wins") {
|
|
op = OpSession
|
|
}
|
|
got, err := c.cfg.EffectiveMode(op, c.dialect)
|
|
if (err != nil) != c.wantErr || got != c.want {
|
|
t.Errorf("%s: got (%q, %v), want (%q, err=%v)", c.name, got, err, c.want, c.wantErr)
|
|
}
|
|
}
|
|
}
|