63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
4.2 KiB
Go
143 lines
4.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
|
ldapv3 "github.com/go-ldap/ldap/v3"
|
|
)
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
func TestResolveBaseDN(t *testing.T) {
|
|
conn := &models.ADConnection{RootDN: "DC=example,DC=com"}
|
|
|
|
if got := resolveBaseDN(&models.Rule{}, conn); got != "DC=example,DC=com" {
|
|
t.Errorf("expected connection RootDN fallback, got %q", got)
|
|
}
|
|
|
|
override := "OU=Staff,DC=example,DC=com"
|
|
rule := &models.Rule{BaseDNOverride: &override}
|
|
if got := resolveBaseDN(rule, conn); got != override {
|
|
t.Errorf("expected rule override, got %q", got)
|
|
}
|
|
|
|
empty := ""
|
|
rule = &models.Rule{BaseDNOverride: &empty}
|
|
if got := resolveBaseDN(rule, conn); got != "DC=example,DC=com" {
|
|
t.Errorf("empty override should fall back to connection, got %q", got)
|
|
}
|
|
|
|
if got := resolveBaseDN(&models.Rule{}, nil); got != "" {
|
|
t.Errorf("nil connection should yield empty string, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveScope(t *testing.T) {
|
|
conn := &models.ADConnection{DefaultSearchScope: "OneLevel"}
|
|
|
|
if got := resolveScope(&models.Rule{}, conn); got != ldapv3.ScopeSingleLevel {
|
|
t.Errorf("expected OneLevel from connection, got %d", got)
|
|
}
|
|
|
|
rule := &models.Rule{SearchScopeOverride: strPtr("Base")}
|
|
if got := resolveScope(rule, conn); got != ldapv3.ScopeBaseObject {
|
|
t.Errorf("expected Base from override, got %d", got)
|
|
}
|
|
|
|
rule = &models.Rule{SearchScopeOverride: strPtr("Subtree")}
|
|
if got := resolveScope(rule, conn); got != ldapv3.ScopeWholeSubtree {
|
|
t.Errorf("Subtree override should produce WholeSubtree, got %d", got)
|
|
}
|
|
|
|
if got := resolveScope(&models.Rule{}, nil); got != ldapv3.ScopeWholeSubtree {
|
|
t.Errorf("empty inputs should default to WholeSubtree, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestCollectAttributes_IncludesDefaultsAndConditionNames(t *testing.T) {
|
|
rule := &models.Rule{
|
|
ConditionGroups: []models.RuleConditionGroup{
|
|
{
|
|
Conditions: []models.RuleCondition{
|
|
{AttributeName: "department"},
|
|
{AttributeName: "title"},
|
|
{AttributeName: ""},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
attrs := collectAttributes(rule)
|
|
|
|
needs := []string{"dn", "cn", "objectClass", "sAMAccountName", "member", "memberOf", "department", "title"}
|
|
for _, want := range needs {
|
|
if !contains(attrs, want) {
|
|
t.Errorf("expected attributes to contain %q, got %v", want, attrs)
|
|
}
|
|
}
|
|
if contains(attrs, "") {
|
|
t.Errorf("empty attribute name should not be collected, got %v", attrs)
|
|
}
|
|
}
|
|
|
|
func TestBuildConditionGroups_FiltersDisabled(t *testing.T) {
|
|
val := "Sales"
|
|
custom := "(memberOf=CN=VIP,DC=x)"
|
|
rule := &models.Rule{
|
|
ConditionGroups: []models.RuleConditionGroup{
|
|
{
|
|
IsEnabled: true,
|
|
JoinOperator: "AND",
|
|
Negate: true,
|
|
Conditions: []models.RuleCondition{
|
|
{IsEnabled: true, AttributeName: "department", Operator: "Equals", ComparisonValue: &val},
|
|
{IsEnabled: false, AttributeName: "title", Operator: "Equals"},
|
|
{IsEnabled: true, Operator: "Custom", CustomLdapExpression: &custom},
|
|
},
|
|
},
|
|
{IsEnabled: false, Conditions: []models.RuleCondition{{IsEnabled: true, AttributeName: "x"}}},
|
|
},
|
|
}
|
|
|
|
groups := buildConditionGroups(rule)
|
|
if len(groups) != 1 {
|
|
t.Fatalf("expected 1 enabled group, got %d", len(groups))
|
|
}
|
|
g := groups[0]
|
|
if !g.Negate || string(g.JoinOperator) != "AND" {
|
|
t.Errorf("unexpected group flags: negate=%v op=%q", g.Negate, g.JoinOperator)
|
|
}
|
|
if len(g.Conditions) != 2 {
|
|
t.Fatalf("expected 2 enabled conditions, got %d", len(g.Conditions))
|
|
}
|
|
if g.Conditions[0].Attribute != "department" || g.Conditions[0].Value != "Sales" {
|
|
t.Errorf("first condition mismatch: %+v", g.Conditions[0])
|
|
}
|
|
if g.Conditions[1].CustomLdap != custom {
|
|
t.Errorf("expected custom expression preserved, got %q", g.Conditions[1].CustomLdap)
|
|
}
|
|
}
|
|
|
|
func TestParentDN(t *testing.T) {
|
|
cases := map[string]string{
|
|
"CN=Alice,OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com",
|
|
"CN=Alice, OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com",
|
|
"DC=com": "",
|
|
"": "",
|
|
}
|
|
for input, want := range cases {
|
|
if got := parentDN(input); got != want {
|
|
t.Errorf("parentDN(%q) = %q, want %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|