webui
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
FROM debian:12-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
dnsutils \
|
||||
krb5-user \
|
||||
ldb-tools \
|
||||
samba \
|
||||
samba-ad-dc \
|
||||
samba-ad-provision \
|
||||
smbclient \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY dev/ad-entrypoint.sh /usr/local/bin/preview-ad-entrypoint
|
||||
COPY dev/preview-client.sh /usr/local/bin/preview-client
|
||||
COPY dev/seed-files.sh /usr/local/bin/preview-seed-files
|
||||
|
||||
RUN chmod +x \
|
||||
/usr/local/bin/preview-ad-entrypoint \
|
||||
/usr/local/bin/preview-client \
|
||||
/usr/local/bin/preview-seed-files
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/usr/local/bin/preview-ad-entrypoint"]
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
log() {
|
||||
printf '[preview-ad] %s\n' "$*"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local name=$1
|
||||
if [[ -z ${!name:-} ]]; then
|
||||
printf '[preview-ad] ERROR: missing %s\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for name in AD_REALM AD_DOMAIN AD_DNS_DOMAIN AD_BASE_DN AD_ADMIN_PASSWORD \
|
||||
AD_USER_PASSWORD AD_WEB_ADMIN_USER AD_WEB_ADMIN_PASSWORD \
|
||||
DEV_CA_IP DEV_BACKUP_IP DEV_FILESERVER_IP; do
|
||||
require_env "$name"
|
||||
done
|
||||
|
||||
if [[ ! -s /var/lib/samba/private/sam.ldb ]]; then
|
||||
log "Provisioning disposable ${AD_REALM} domain"
|
||||
rm -f /etc/samba/smb.conf
|
||||
samba-tool domain provision \
|
||||
--server-role=dc \
|
||||
--use-rfc2307 \
|
||||
--dns-backend=SAMBA_INTERNAL \
|
||||
--realm="$AD_REALM" \
|
||||
--domain="$AD_DOMAIN" \
|
||||
--adminpass="$AD_ADMIN_PASSWORD"
|
||||
fi
|
||||
|
||||
ln -sf /var/lib/samba/private/krb5.conf /etc/krb5.conf
|
||||
|
||||
log 'Starting Samba AD DC'
|
||||
samba -i --no-process-group &
|
||||
samba_pid=$!
|
||||
|
||||
stop_samba() {
|
||||
kill "$samba_pid" >/dev/null 2>&1 || true
|
||||
wait "$samba_pid" 2>/dev/null || true
|
||||
}
|
||||
trap stop_samba EXIT INT TERM HUP
|
||||
|
||||
ready=0
|
||||
for _ in $(seq 1 90); do
|
||||
if samba-tool domain info 127.0.0.1 >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ $ready != 1 ]]; then
|
||||
printf '[preview-ad] ERROR: domain controller did not become ready\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_user() {
|
||||
local user=$1
|
||||
local password=$2
|
||||
local given=$3
|
||||
local surname=$4
|
||||
if ! samba-tool user show "$user" >/dev/null 2>&1; then
|
||||
samba-tool user create "$user" "$password" \
|
||||
--given-name="$given" --surname="$surname" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_group() {
|
||||
local group=$1
|
||||
if ! samba-tool group show "$group" >/dev/null 2>&1; then
|
||||
samba-tool group add "$group" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
add_members() {
|
||||
local group=$1
|
||||
local members=$2
|
||||
samba-tool group addmembers "$group" "$members" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
set_display_name() {
|
||||
local group=$1
|
||||
local display_name=$2
|
||||
ldbmodify -H /var/lib/samba/private/sam.ldb >/dev/null <<EOF
|
||||
dn: CN=${group},CN=Users,${AD_BASE_DN}
|
||||
changetype: modify
|
||||
replace: displayName
|
||||
displayName: ${display_name}
|
||||
EOF
|
||||
}
|
||||
|
||||
log 'Seeding users and nested file-share groups'
|
||||
ensure_user alice "$AD_USER_PASSWORD" Alice Adams
|
||||
ensure_user bob "$AD_USER_PASSWORD" Bob Brown
|
||||
ensure_user carol "$AD_USER_PASSWORD" Carol Clark
|
||||
ensure_user dave "$AD_USER_PASSWORD" Dave Davis
|
||||
ensure_user eve "$AD_USER_PASSWORD" Eve Evans
|
||||
ensure_user "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator
|
||||
|
||||
for group in Finance_Analysts Engineering_Leads FS_Finance FS_Engineering FS_Projects; do
|
||||
ensure_group "$group"
|
||||
done
|
||||
|
||||
set_display_name Finance_Analysts 'Finance Analysts'
|
||||
set_display_name Engineering_Leads 'Engineering Leads'
|
||||
set_display_name FS_Finance Finance
|
||||
set_display_name FS_Engineering Engineering
|
||||
set_display_name FS_Projects Projects
|
||||
|
||||
add_members Finance_Analysts alice
|
||||
add_members FS_Finance 'Finance_Analysts,bob'
|
||||
add_members Engineering_Leads carol
|
||||
add_members FS_Engineering 'Engineering_Leads,dave'
|
||||
add_members FS_Projects 'FS_Finance,FS_Engineering,eve'
|
||||
add_members 'Domain Admins' "$AD_WEB_ADMIN_USER"
|
||||
|
||||
add_dns_record() {
|
||||
local name=$1
|
||||
local address=$2
|
||||
samba-tool dns add 127.0.0.1 "$AD_DNS_DOMAIN" "$name" A "$address" \
|
||||
-U "Administrator%${AD_ADMIN_PASSWORD}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
add_dns_record ca "$DEV_CA_IP"
|
||||
add_dns_record backup "$DEV_BACKUP_IP"
|
||||
add_dns_record files "$DEV_FILESERVER_IP"
|
||||
|
||||
domain_sid=$(ldbsearch -H /var/lib/samba/private/sam.ldb \
|
||||
-b "$AD_BASE_DN" -s base objectSid 2>/dev/null \
|
||||
| sed -n 's/^objectSid: //p' | head -n1)
|
||||
if [[ -z $domain_sid ]]; then
|
||||
printf '[preview-ad] ERROR: unable to determine domain SID\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$domain_sid" > /run/domain-sid
|
||||
touch /run/preview-ready
|
||||
log "Ready: ${AD_DOMAIN} (${domain_sid})"
|
||||
|
||||
wait "$samba_pid"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
: "${BACKUP_USERNAME:?missing BACKUP_USERNAME}"
|
||||
: "${BACKUP_PASSWORD:?missing BACKUP_PASSWORD}"
|
||||
backup_base_dir=${BACKUP_BASE_DIR:-fileserver}
|
||||
|
||||
mkdir -p "/backup/${backup_base_dir}/snapshots"
|
||||
|
||||
cat > /etc/rsyncd.conf <<EOF
|
||||
uid = root
|
||||
gid = root
|
||||
use chroot = no
|
||||
max connections = 8
|
||||
pid file = /run/rsyncd.pid
|
||||
lock file = /run/rsyncd.lock
|
||||
log file = /dev/stdout
|
||||
timeout = 300
|
||||
|
||||
[backups]
|
||||
path = /backup
|
||||
read only = false
|
||||
list = true
|
||||
auth users = ${BACKUP_USERNAME}
|
||||
secrets file = /etc/rsyncd.secrets
|
||||
EOF
|
||||
|
||||
printf '%s:%s\n' "$BACKUP_USERNAME" "$BACKUP_PASSWORD" > /etc/rsyncd.secrets
|
||||
chmod 0600 /etc/rsyncd.secrets
|
||||
|
||||
printf '[preview-backup] rsync://0.0.0.0/backups ready\n'
|
||||
exec rsync --daemon --no-detach --config=/etc/rsyncd.conf
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM debian:12-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends rsync tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /backup
|
||||
|
||||
COPY dev/backup-entrypoint.sh /usr/local/bin/preview-backup-entrypoint
|
||||
RUN chmod +x /usr/local/bin/preview-backup-entrypoint
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/preview-backup-entrypoint"]
|
||||
Executable
+328
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end checks for the disposable preview domain and file server."""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
|
||||
ENGINE = os.environ["PREVIEW_ENGINE"]
|
||||
FILES_CONTAINER = os.environ["PREVIEW_FILES_CONTAINER"]
|
||||
CLIENT_CONTAINER = os.environ["PREVIEW_CLIENT_CONTAINER"]
|
||||
BACKUP_CONTAINER = os.environ["PREVIEW_BACKUP_CONTAINER"]
|
||||
CA_ROOT = os.environ["PREVIEW_CA_ROOT"]
|
||||
HTTPS_PORT = int(os.environ["PREVIEW_HTTPS_PORT"])
|
||||
REALM = os.environ["PREVIEW_REALM"]
|
||||
WORKGROUP = os.environ["PREVIEW_WORKGROUP"]
|
||||
DNS_DOMAIN = os.environ["PREVIEW_DNS_DOMAIN"]
|
||||
DOMAIN_SID = os.environ["PREVIEW_DOMAIN_SID"]
|
||||
ADMIN_USER = os.environ["PREVIEW_ADMIN_USER"]
|
||||
ADMIN_PASSWORD = os.environ["PREVIEW_ADMIN_PASSWORD"]
|
||||
USER_PASSWORD = os.environ["PREVIEW_USER_PASSWORD"]
|
||||
BASE_URL = f"https://localhost:{HTTPS_PORT}"
|
||||
TLS_CONTEXT = ssl.create_default_context(cafile=CA_ROOT)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
status: int
|
||||
headers: object
|
||||
body: bytes
|
||||
|
||||
def json(self):
|
||||
return json.loads(self.body.decode("utf-8"))
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def check(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
fail(message)
|
||||
|
||||
|
||||
def announce(message: str) -> None:
|
||||
print(f"[e2e] {message}", flush=True)
|
||||
|
||||
|
||||
def engine_run(*args: str, check_result: bool = True) -> subprocess.CompletedProcess:
|
||||
result = subprocess.run(
|
||||
[ENGINE, *args], capture_output=True, text=True, check=False
|
||||
)
|
||||
if check_result and result.returncode != 0:
|
||||
output = result.stderr.strip() or result.stdout.strip()
|
||||
fail(f"container command failed ({' '.join(args)}): {output}")
|
||||
return result
|
||||
|
||||
|
||||
def http(
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
value: Optional[Dict[str, object]] = None,
|
||||
token: str = "",
|
||||
) -> Response:
|
||||
body = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if value is not None:
|
||||
body = json.dumps(value).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}{path}", data=body, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, context=TLS_CONTEXT, timeout=30
|
||||
) as response:
|
||||
return Response(response.status, response.headers, response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return Response(exc.code, exc.headers, exc.read())
|
||||
|
||||
|
||||
def eventually(
|
||||
description: str,
|
||||
callback: Callable[[], object],
|
||||
predicate: Callable[[object], bool],
|
||||
timeout: float = 90,
|
||||
interval: float = 1,
|
||||
):
|
||||
deadline = time.monotonic() + timeout
|
||||
last_value = None
|
||||
last_error: Optional[Exception] = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
last_value = callback()
|
||||
if predicate(last_value):
|
||||
return last_value
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
last_error = exc
|
||||
time.sleep(interval)
|
||||
detail = f"; last value={last_value!r}"
|
||||
if last_error is not None:
|
||||
detail += f"; last error={last_error}"
|
||||
fail(f"timed out waiting for {description}{detail}")
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> Dict[str, object]:
|
||||
parts = token.split(".")
|
||||
check(len(parts) == 3, "login did not return a compact JWT")
|
||||
padding = "=" * (-len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(parts[1] + padding))
|
||||
|
||||
|
||||
def flatten_members(nodes):
|
||||
values = []
|
||||
for node in nodes:
|
||||
values.append((node.get("type"), node.get("sam"), node.get("name")))
|
||||
values.extend(flatten_members(node.get("members", [])))
|
||||
return values
|
||||
|
||||
|
||||
def query_path(path: str, params: Dict[str, str]) -> str:
|
||||
return f"{path}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
announce("TLS chain, hostname, public health, and browser security headers")
|
||||
health = http("/healthz")
|
||||
check(health.status == 200 and health.json() == {"status": "ok"}, "health check failed")
|
||||
index = http("/")
|
||||
check(index.status == 200 and b'Dateiserver-Verwaltung' in index.body, "German web shell was not served")
|
||||
check(b'<html lang="de">' in index.body, "web shell language is not German")
|
||||
styles = http("/assets/styles.css")
|
||||
check(b"[hidden]" in styles.body and b"display: none !important" in styles.body, "hidden views can be made visible by author CSS")
|
||||
script = http("/assets/app.js")
|
||||
check(b"getUTCHours()" in script.body and b" UTC`" in script.body, "UI does not format timestamps explicitly in UTC")
|
||||
check(b"localTime" not in script.body and b"eyebrow" not in script.body, "obsolete local-time or decorative UI code remains")
|
||||
check(b"brand-mark" not in index.body, "decorative brand mark remains")
|
||||
check("max-age=" in index.headers.get("Strict-Transport-Security", ""), "HSTS missing")
|
||||
check("default-src 'self'" in index.headers.get("Content-Security-Policy", ""), "CSP missing")
|
||||
with socket.create_connection(("localhost", HTTPS_PORT), timeout=10) as raw:
|
||||
with TLS_CONTEXT.wrap_socket(raw, server_hostname="localhost") as secured:
|
||||
certificate = secured.getpeercert()
|
||||
sans = {value for kind, value in certificate.get("subjectAltName", ()) if kind == "DNS"}
|
||||
check("localhost" in sans, "issued certificate does not cover localhost")
|
||||
check(certificate.get("issuer") != certificate.get("subject"), "web certificate is self-signed instead of CA-issued")
|
||||
|
||||
announce("JWT boundary, real Kerberos credentials, and Domain Admin authorization")
|
||||
unauthenticated = http("/api/session")
|
||||
check(unauthenticated.status == 401, "protected API accepted an anonymous request")
|
||||
non_admin = http(
|
||||
"/api/login",
|
||||
method="POST",
|
||||
value={"username": "alice", "password": USER_PASSWORD},
|
||||
)
|
||||
check(non_admin.status == 401, "valid non-admin domain user was allowed into the UI")
|
||||
wrong_password = http(
|
||||
"/api/login",
|
||||
method="POST",
|
||||
value={"username": ADMIN_USER, "password": "wrong-password"},
|
||||
)
|
||||
check(wrong_password.status == 401, "invalid admin password was accepted")
|
||||
login = http(
|
||||
"/api/login",
|
||||
method="POST",
|
||||
value={"username": ADMIN_USER, "password": ADMIN_PASSWORD},
|
||||
)
|
||||
check(login.status == 200, f"Domain Admin login failed: {login.body!r}")
|
||||
login_payload = login.json()
|
||||
token = str(login_payload.get("token", ""))
|
||||
claims = decode_jwt_payload(token)
|
||||
check(claims.get("iss") == "ad-file-server-web", "JWT issuer is wrong")
|
||||
check(claims.get("aud") == "domain-admins", "JWT audience is wrong")
|
||||
check(claims.get("role") == "domain-admin", "JWT role is wrong")
|
||||
check(claims.get("sub") == f"{WORKGROUP}\\{ADMIN_USER}", "JWT subject is wrong")
|
||||
cookie = login.headers.get("Set-Cookie", "")
|
||||
for attribute in ("HttpOnly", "Secure", "SameSite=Strict"):
|
||||
check(attribute in cookie, f"session cookie is missing {attribute}")
|
||||
session = http("/api/session", token=token)
|
||||
check(session.status == 200 and session.json().get("user") == claims["sub"], "Bearer JWT was not accepted")
|
||||
check(http("/api/session", token=f"{token}corrupt").status == 401, "corrupt JWT was accepted")
|
||||
readonly = http("/api/groups", method="POST", value={}, token=token)
|
||||
check(readonly.status == 404, "a mutation-like API method was accepted")
|
||||
|
||||
announce("AD trust, nested group tree, folders, and domain membership")
|
||||
engine_run("exec", FILES_CONTAINER, "wbinfo", "-t")
|
||||
admin_identity = engine_run(
|
||||
"exec", FILES_CONTAINER, "wbinfo", "--name-to-sid", f"{WORKGROUP}\\{ADMIN_USER}"
|
||||
)
|
||||
admin_sid = admin_identity.stdout.split()[0]
|
||||
admin_sids = engine_run(
|
||||
"exec", FILES_CONTAINER, "wbinfo", "--user-sids", admin_sid
|
||||
)
|
||||
check(f"{DOMAIN_SID}-512" in admin_sids.stdout, "preview admin SID set lacks Domain Admins")
|
||||
groups_response = http("/api/groups", token=token)
|
||||
check(groups_response.status == 200, f"group endpoint failed: {groups_response.body!r}")
|
||||
groups_payload = groups_response.json()
|
||||
groups = {row["name"]: row for row in groups_payload.get("groups", [])}
|
||||
check({"Finance", "Engineering", "Projects"}.issubset(groups), f"seed groups missing: {sorted(groups)}")
|
||||
check(groups["Finance"]["folder"] == "Finance", "Finance group/folder mapping is wrong")
|
||||
finance_nodes = flatten_members(groups["Finance"].get("members", []))
|
||||
check(any(kind == "group" and sam == "Finance_Analysts" for kind, sam, _ in finance_nodes), "nested Finance Analysts group missing")
|
||||
check({"alice", "bob"}.issubset({sam for kind, sam, _ in finance_nodes if kind == "user"}), "Finance users missing")
|
||||
project_nodes = flatten_members(groups["Projects"].get("members", []))
|
||||
check({"alice", "bob", "carol", "dave", "eve"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive Project membership is incomplete")
|
||||
|
||||
announce("SMB authorization and real share reads/writes")
|
||||
alice_access = engine_run(
|
||||
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
|
||||
"-U", f"{WORKGROUP}\\alice%{USER_PASSWORD}", "-c", "cd Finance; ls",
|
||||
check_result=False,
|
||||
)
|
||||
check(alice_access.returncode == 0, f"Alice cannot access Finance: {alice_access.stderr}")
|
||||
dave_denied = engine_run(
|
||||
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
|
||||
"-U", f"{WORKGROUP}\\dave%{USER_PASSWORD}", "-c", "cd Finance; ls",
|
||||
check_result=False,
|
||||
)
|
||||
check(dave_denied.returncode != 0, "unrelated user Dave can access Finance")
|
||||
admin_access = engine_run(
|
||||
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
|
||||
"-U", f"{WORKGROUP}\\{ADMIN_USER}%{ADMIN_PASSWORD}", "-c", "cd Finance; ls",
|
||||
check_result=False,
|
||||
)
|
||||
check(admin_access.returncode == 0, "Domain Admin cannot inspect Finance")
|
||||
|
||||
announce("group, Private, and FSLogix size accounting")
|
||||
storage = http("/api/storage", token=token)
|
||||
check(storage.status == 200, f"storage endpoint failed: {storage.body!r}")
|
||||
storage_payload = storage.json()
|
||||
storage_groups = {row["name"]: int(row["bytes"]) for row in storage_payload.get("groups", [])}
|
||||
check(storage_groups.get("Finance", 0) >= 1024 * 1024, "Finance usage was not scanned")
|
||||
check(storage_groups.get("Engineering", 0) >= 1024 * 1024, "Engineering usage was not scanned")
|
||||
users = {row["name"].casefold(): row for row in storage_payload.get("users", [])}
|
||||
check(int(users.get("alice", {}).get("privateBytes", 0)) > 0, "Alice Private usage missing")
|
||||
check(int(users.get("alice", {}).get("fslogixBytes", 0)) > 0, "Alice FSLogix usage missing")
|
||||
check(int(storage_payload.get("totals", {}).get("dataBytes", 0)) > 0, "Data total is empty")
|
||||
|
||||
announce("live Samba audit ingestion, filters, facets, and pagination")
|
||||
activity = eventually(
|
||||
"live alice audit records",
|
||||
lambda: http(query_path("/api/activity", {"user": "alice", "limit": "5"}), token=token),
|
||||
lambda response: response.status == 200 and response.json().get("matched", 0) >= 2,
|
||||
timeout=60,
|
||||
)
|
||||
activity_payload = activity.json()
|
||||
check(all("alice" in str(event.get("user", "")).casefold() for event in activity_payload["events"]), "activity user filter leaked unrelated events")
|
||||
check("Data" in activity_payload.get("facets", {}).get("shares", []), "activity share facets missing Data")
|
||||
one_event = http(query_path("/api/activity", {"limit": "1"}), token=token).json()
|
||||
check(len(one_event.get("events", [])) == 1, "activity limit was ignored")
|
||||
check(one_event.get("nextCursor") is not None, "activity pagination cursor missing")
|
||||
|
||||
announce("closed daily log compression and querying gzip history")
|
||||
old_day = (dt.datetime.now(dt.timezone.utc).date() - dt.timedelta(days=2)).isoformat()
|
||||
eventually(
|
||||
"historical audit gzip",
|
||||
lambda: engine_run(
|
||||
"exec", FILES_CONTAINER, "test", "-f", f"/state/audit/{old_day}.jsonl.gz",
|
||||
check_result=False,
|
||||
).returncode,
|
||||
lambda returncode: returncode == 0,
|
||||
timeout=30,
|
||||
)
|
||||
archived = http(
|
||||
query_path(
|
||||
"/api/activity",
|
||||
{"from": old_day, "to": old_day, "user": "archived-user", "limit": "10"},
|
||||
),
|
||||
token=token,
|
||||
)
|
||||
check(archived.status == 200 and archived.json().get("matched") == 1, "compressed audit history is not searchable")
|
||||
|
||||
announce("real rsync backup, status API, log tail, and remote completion marker")
|
||||
backup = eventually(
|
||||
"completed backup",
|
||||
lambda: http("/api/backup", token=token),
|
||||
lambda response: response.status == 200 and response.json().get("state") in {"completed", "failed"},
|
||||
timeout=240,
|
||||
interval=2,
|
||||
)
|
||||
backup_payload = backup.json()
|
||||
check(backup_payload.get("state") == "completed", f"backup failed: {backup_payload}")
|
||||
check(float(backup_payload.get("percent", 0)) == 100.0, "completed backup is not at 100%")
|
||||
check(any("completed" in line.casefold() for line in backup_payload.get("log", [])), "backup completion absent from log tail")
|
||||
marker = engine_run(
|
||||
"exec", BACKUP_CONTAINER, "sh", "-ec",
|
||||
"find /backup/fileserver/snapshots -name .backup_complete -type f | grep -q .",
|
||||
check_result=False,
|
||||
)
|
||||
check(marker.returncode == 0, "backup target has no completed snapshot marker")
|
||||
|
||||
announce("overview and system health aggregation")
|
||||
overview = http("/api/overview", token=token)
|
||||
check(overview.status == 200 and overview.json().get("activeGroups") == 3, "overview group count is wrong")
|
||||
system = http("/api/system", token=token)
|
||||
check(system.status == 200, f"system endpoint failed: {system.body!r}")
|
||||
system_payload = system.json()
|
||||
check(system_payload.get("checks", {}).get("domainTrust") is True, "system reports broken AD trust")
|
||||
check(system_payload.get("checks", {}).get("sambaConfig") is True, "system reports invalid Samba config")
|
||||
check(system_payload.get("tls", {}).get("sans"), "system TLS summary is empty")
|
||||
check(system_payload.get("audit", {}).get("days", 0) >= 2, "system audit archive summary is incomplete")
|
||||
|
||||
logout = http("/api/logout", method="POST", value={}, token=token)
|
||||
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
|
||||
|
||||
announce("PASS: all end-to-end assertions succeeded")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
print(f"[e2e] FAIL: {exc}", file=sys.stderr, flush=True)
|
||||
sys.exit(1)
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
: "${AD_DOMAIN:?missing AD_DOMAIN}"
|
||||
: "${AD_USER_PASSWORD:?missing AD_USER_PASSWORD}"
|
||||
: "${FILESERVER_HOST:?missing FILESERVER_HOST}"
|
||||
|
||||
interval=${DEV_ACTIVITY_INTERVAL_SECONDS:-4}
|
||||
|
||||
log() {
|
||||
printf '[preview-client] %s\n' "$*"
|
||||
}
|
||||
|
||||
smb() {
|
||||
local user=$1
|
||||
local share=$2
|
||||
local commands=$3
|
||||
smbclient "//${FILESERVER_HOST}/${share}" \
|
||||
-m SMB3 -U "${AD_DOMAIN}\\${user}%${AD_USER_PASSWORD}" \
|
||||
-c "$commands"
|
||||
}
|
||||
|
||||
ready=0
|
||||
for _ in $(seq 1 120); do
|
||||
if smb alice Data 'ls' >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ $ready != 1 ]]; then
|
||||
printf '[preview-client] ERROR: SMB server did not become ready\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Generated by the preview SMB client.\n' > /tmp/live-note.txt
|
||||
smb alice Data 'cd Finance; mkdir Reports; cd Reports; put /tmp/live-note.txt live-note.txt; ls' >/dev/null
|
||||
smb carol Data 'cd Engineering; mkdir Designs; cd Designs; put /tmp/live-note.txt architecture.txt; ls' >/dev/null
|
||||
smb alice Private 'cd alice; put /tmp/live-note.txt notes.txt; ls' >/dev/null
|
||||
touch /run/preview-client-ready
|
||||
log 'Initial SMB reads and writes complete; generating live activity'
|
||||
|
||||
counter=0
|
||||
while true; do
|
||||
counter=$((counter + 1))
|
||||
printf 'Preview activity event %d at %s\n' "$counter" "$(date -u +%FT%TZ)" > /tmp/live-note.txt
|
||||
smb alice Data 'cd Finance; cd Reports; put /tmp/live-note.txt live-note.txt; get live-note.txt /tmp/readback.txt; ls' >/dev/null 2>&1 || true
|
||||
smb bob Data 'cd Finance; ls' >/dev/null 2>&1 || true
|
||||
smb carol Data 'cd Engineering; cd Designs; get architecture.txt /tmp/architecture.txt; ls' >/dev/null 2>&1 || true
|
||||
smb eve Data 'cd Projects; ls' >/dev/null 2>&1 || true
|
||||
smb alice Private 'cd alice; ls; get notes.txt /tmp/private-note.txt' >/dev/null 2>&1 || true
|
||||
sleep "$interval"
|
||||
done
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
seed_mb=${DEV_SEED_MB:-8}
|
||||
|
||||
mkdir -p \
|
||||
/data/groups/data/Finance/Reports \
|
||||
/data/groups/data/Engineering/Designs \
|
||||
/data/groups/data/Projects/Planning \
|
||||
/data/private/alice \
|
||||
/data/private/bob \
|
||||
/data/private/carol \
|
||||
/data/private/dave \
|
||||
/data/private/eve \
|
||||
/data/fslogix/alice_S-1-5-21-111-222-333-1101 \
|
||||
/data/fslogix/carol_S-1-5-21-111-222-333-1103 \
|
||||
/state/audit
|
||||
|
||||
printf 'Quarter,Forecast\nQ1,120000\nQ2,135000\n' > /data/groups/data/Finance/Reports/forecast.csv
|
||||
printf '# Architecture\n\nPreview design notes.\n' > /data/groups/data/Engineering/Designs/architecture.md
|
||||
printf 'Milestone,Owner\nDiscovery,Eve\nDelivery,Carol\n' > /data/groups/data/Projects/Planning/roadmap.csv
|
||||
printf 'Alice private preview data.\n' > /data/private/alice/readme.txt
|
||||
printf 'Bob private preview data.\n' > /data/private/bob/readme.txt
|
||||
printf 'Carol private preview data.\n' > /data/private/carol/readme.txt
|
||||
printf 'Dummy FSLogix profile for Alice.\n' > /data/fslogix/alice_S-1-5-21-111-222-333-1101/profile.vhdx
|
||||
printf 'Dummy FSLogix profile for Carol.\n' > /data/fslogix/carol_S-1-5-21-111-222-333-1103/profile.vhdx
|
||||
|
||||
dd if=/dev/zero of=/data/groups/data/Finance/Reports/history.bin bs=1M count="$seed_mb" status=none
|
||||
dd if=/dev/zero of=/data/groups/data/Engineering/Designs/models.bin bs=1M count="$seed_mb" status=none
|
||||
dd if=/dev/zero of=/data/fslogix/alice_S-1-5-21-111-222-333-1101/profile-data.bin bs=1M count="$seed_mb" status=none
|
||||
|
||||
old_day=$(date -u -d '2 days ago' +%F)
|
||||
printf '%s\n' "{\"action\":\"read\",\"client\":\"archived-client\",\"clientIp\":\"192.0.2.50\",\"ingestedAt\":\"${old_day}T12:00:00+00:00\",\"operation\":\"read\",\"path\":\"Finance/Reports/archive.csv\",\"result\":\"OK\",\"share\":\"Data\",\"source\":\"log.archived-client\",\"success\":true,\"timestamp\":\"${old_day}T12:00:00+00:00\",\"user\":\"archived-user\"}" \
|
||||
> "/state/audit/${old_day}.jsonl"
|
||||
touch -d '2 days ago' "/state/audit/${old_day}.jsonl"
|
||||
|
||||
printf '[preview-seed] Seeded group, private, FSLogix, and historical audit data.\n'
|
||||
Reference in New Issue
Block a user