Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
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>
2026-09-03 13:57:15 -04:00

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
}