The human install surface (subplan 04 Slice 5), honest per PLAN.md's iOS Install Policy. App gains ios_mode (artifact_only default / testflight / enterprise) and testflight_url; absent db.json members load as defaults. dist app set is the admin mutator (apps are created by publish), enforcing the policy preconditions as machine-readable errors: testflight requires --testflight-url, enterprise requires --ios-bundle-id. Every set appends an app.update audit event. GET /install/<slug>/<channel> renders the channel's current release as platform sections — the User-Agent's platform ordered first and marked — with per-mode iOS actions: TestFlight link, itms-services OTA deep link, or an IPA download explicitly labeled as not installable from the page. Size + sha256 are visible on every artifact row. GET /install/<slug>/<channel>/manifest.plist serves the enterprise OTA manifest (bundle id, version, https package URL off the Host header) and 404s in any other mode. The index links channels to their pages. KNOWN sx BOUNDARY (issue 0098): an enum literal returned directly into an optional target silently lowers to variant 0 — ua_platform routes every variant through a typed local. make test 19/19 (new: server_install.sx pinned acceptance).
220 lines
11 KiB
Plaintext
220 lines
11 KiB
Plaintext
// Pinned acceptance for P4.6 — install pages with accurate iOS modes
|
|
// (subplan 04 Slice 5; PLAN.md "iOS Install Policy").
|
|
//
|
|
// Publishes the APK+IPA example into a fresh store, starts the BUILT
|
|
// server, and walks `dist app set` through the three iOS modes asserting
|
|
// the page and OTA manifest at each step:
|
|
//
|
|
// * default (artifact_only): the IPA is a labeled download that
|
|
// explicitly does NOT claim on-device install; manifest.plist is 404
|
|
// (install.not_enterprise). The APK row shows its sha256.
|
|
// * testflight: setting the mode without a URL exits 1
|
|
// (app.testflight_url_required); with one, the page links TestFlight.
|
|
// * enterprise: without a bundle id exits 1 (app.bundle_id_required);
|
|
// with one, the page carries the itms-services deep link and
|
|
// manifest.plist serves XML naming bundle id, version, and an
|
|
// https://<host>/download/<sha> package URL. Flipping the mode back
|
|
// 404s the plist again — all without restarting the server.
|
|
// * UA detection: an iPhone UA marks the iOS section, an Android UA
|
|
// the Android section.
|
|
// * 404s: unknown slug/channel, channel-less path; the index links
|
|
// every channel to its install page.
|
|
#import "modules/std.sx";
|
|
#import "modules/std/json.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
fs :: #import "modules/std/fs.sx";
|
|
hash :: #import "modules/std/hash.sx";
|
|
|
|
STORE :: ".sx-tmp/server_install";
|
|
PORT :: "18797";
|
|
BASE :: "http://127.0.0.1:18797";
|
|
|
|
get :: (o: Object, key: string) -> Value {
|
|
i := 0;
|
|
while i < o.len {
|
|
if o.items[i].key == key { return o.items[i].val; }
|
|
i += 1;
|
|
}
|
|
process.assert(false, concat("missing json key: ", key));
|
|
dummy : Value = .null_;
|
|
return dummy;
|
|
}
|
|
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
|
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
|
|
|
contains :: (hay: string, needle: string) -> bool {
|
|
if needle.len == 0 { return true; }
|
|
if needle.len > hay.len { return false; }
|
|
i := 0;
|
|
while i + needle.len <= hay.len {
|
|
j := 0;
|
|
ok := true;
|
|
while j < needle.len {
|
|
if hay[i + j] != needle[j] { ok = false; break; }
|
|
j += 1;
|
|
}
|
|
if ok { return true; }
|
|
i += 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
|
|
v, e := parse(body, scratch);
|
|
if e {
|
|
process.assert(false, concat("must be valid JSON: ", what));
|
|
dummy : Object = .{};
|
|
return dummy;
|
|
}
|
|
return v.object;
|
|
}
|
|
|
|
fetch :: (path: string) -> string {
|
|
r := process.run(concat(concat("curl -s -m 2 ", BASE), path));
|
|
process.assert(r != null, concat("curl spawn failed: ", path));
|
|
return r!.stdout;
|
|
}
|
|
|
|
fetch_code :: (path: string) -> string {
|
|
r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), path));
|
|
process.assert(r != null, concat("curl spawn failed: ", path));
|
|
return r!.stdout;
|
|
}
|
|
|
|
fetch_ua :: (path: string, ua: string) -> string {
|
|
cmd := concat("curl -s -m 2 -A '", concat(ua, "' "));
|
|
r := process.run(concat(cmd, concat(BASE, path)));
|
|
process.assert(r != null, concat("curl-ua spawn failed: ", path));
|
|
return r!.stdout;
|
|
}
|
|
|
|
// Run `dist app set` with extra `flags`; returns the run result.
|
|
app_set :: (flags: string) -> ?process.ProcessResult {
|
|
cmd := "build/dist app set --app acme-app --local-store .sx-tmp/server_install ";
|
|
return process.run(concat(cmd, concat(flags, " --json 2>/dev/null")));
|
|
}
|
|
|
|
assert_set_fails :: (r: ?process.ProcessResult, code: string, what: string, scratch: Allocator) {
|
|
process.assert(r != null, concat("spawn failed: ", what));
|
|
process.assert(r!.exit_code == 1, concat("must exit 1: ", what));
|
|
o := parse_body(r!.stdout, what, scratch);
|
|
process.assert(get_str(get_obj(o, "error"), "code") == code,
|
|
concat(concat("error code must be ", code), concat(": ", what)));
|
|
}
|
|
|
|
// sha256 of a fixture file as a heap string.
|
|
file_sha :: (path: string) -> string {
|
|
b := fs.read_file(path);
|
|
process.assert(b != null, concat("fixture must be readable: ", path));
|
|
d := hash.sha256_hex(b!);
|
|
v := string.{ ptr = @d[0], len = 64 };
|
|
return substr(v, 0, 64);
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
gpa := GPA.init();
|
|
arena := Arena.init(xx gpa, 1 << 20);
|
|
defer arena.deinit();
|
|
|
|
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_install' 2>/dev/null");
|
|
process.run(concat("rm -rf ", STORE));
|
|
|
|
pub := process.run("build/dist ci publish --manifest examples/dist.json --local-store .sx-tmp/server_install --json 2>/dev/null >/dev/null");
|
|
process.assert(pub != null and pub!.exit_code == 0, "publish must exit 0");
|
|
|
|
apk_sha := file_sha("examples/fixtures/acme-1.2.3-android.apk");
|
|
ipa_sha := file_sha("examples/fixtures/acme-1.2.3-ios.ipa");
|
|
|
|
sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), ""));
|
|
process.assert(sp != null, "server spawn failed");
|
|
pid := sp!.stdout;
|
|
|
|
ready := false;
|
|
tries := 0;
|
|
while tries < 50 {
|
|
if fetch_code("/healthz") == "200" { ready = true; break; }
|
|
process.run("sleep 0.2");
|
|
tries += 1;
|
|
}
|
|
process.assert(ready, "server must answer /healthz within 10s");
|
|
print(" server up\n");
|
|
|
|
PAGE :: "/install/acme-app/stable";
|
|
PLIST :: "/install/acme-app/stable/manifest.plist";
|
|
|
|
// ── default: artifact_only, honest labeling ───────────────────────
|
|
process.assert(fetch_code(PAGE) == "200", "install page is 200");
|
|
page := fetch(PAGE);
|
|
process.assert(contains(page, "cannot be installed on an iPhone"),
|
|
"artifact_only page disclaims on-device install");
|
|
process.assert(!contains(page, "itms-services") and !contains(page, "TestFlight"),
|
|
"artifact_only page offers no install action");
|
|
process.assert(contains(page, apk_sha), "APK sha256 is visible on the page");
|
|
process.assert(contains(page, concat("/download/", ipa_sha)), "IPA is downloadable");
|
|
process.assert(fetch_code(PLIST) == "404", "plist is 404 outside enterprise mode");
|
|
pe := parse_body(fetch(PLIST), "plist error", xx arena);
|
|
process.assert(get_str(get_obj(pe, "error"), "code") == "install.not_enterprise",
|
|
"plist names install.not_enterprise");
|
|
print(" artifact_only: labeled honestly, sha visible, no plist\n");
|
|
|
|
// ── testflight ─────────────────────────────────────────────────────
|
|
assert_set_fails(app_set("--ios-mode testflight"), "app.testflight_url_required",
|
|
"testflight without url", xx arena);
|
|
tf := app_set("--ios-mode testflight --testflight-url https://testflight.apple.com/join/abc123");
|
|
process.assert(tf != null and tf!.exit_code == 0, "testflight set must exit 0");
|
|
page2 := fetch(PAGE);
|
|
process.assert(contains(page2, "Open in TestFlight"), "testflight page links TestFlight");
|
|
process.assert(contains(page2, "https://testflight.apple.com/join/abc123"), "page carries the TestFlight URL");
|
|
print(" testflight: precondition gated, page links Apple's flow\n");
|
|
|
|
// ── enterprise ─────────────────────────────────────────────────────
|
|
assert_set_fails(app_set("--ios-mode enterprise"), "app.bundle_id_required",
|
|
"enterprise without bundle id", xx arena);
|
|
en := app_set("--ios-mode enterprise --ios-bundle-id co.acme.app");
|
|
process.assert(en != null and en!.exit_code == 0, "enterprise set must exit 0");
|
|
page3 := fetch(PAGE);
|
|
process.assert(contains(page3, "itms-services://"), "enterprise page carries the OTA deep link");
|
|
|
|
process.assert(fetch_code(PLIST) == "200", "enterprise plist is 200");
|
|
plist := fetch(PLIST);
|
|
process.assert(contains(plist, "<key>bundle-identifier</key><string>co.acme.app</string>"),
|
|
"plist names the bundle id");
|
|
process.assert(contains(plist, "<key>bundle-version</key><string>1.2.3</string>"),
|
|
"plist names the release version");
|
|
process.assert(contains(plist, concat("https://127.0.0.1:18797/download/", ipa_sha)),
|
|
"plist package URL is https and content-addressed");
|
|
print(" enterprise: plist serves bundle id, version, https package URL\n");
|
|
|
|
// mode change is visible without a restart (per-request reload)
|
|
back := app_set("--ios-mode testflight");
|
|
process.assert(back != null and back!.exit_code == 0, "flip back to testflight must exit 0");
|
|
process.assert(fetch_code(PLIST) == "404", "plist 404s again after leaving enterprise mode");
|
|
print(" mode flips apply without restart\n");
|
|
|
|
// ── UA detection ───────────────────────────────────────────────────
|
|
ipage := fetch_ua(PAGE, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)");
|
|
process.assert(contains(ipage, "<section class=\"detected\"><h2>iOS"),
|
|
"iPhone UA marks the iOS section");
|
|
apage := fetch_ua(PAGE, "Mozilla/5.0 (Linux; Android 14; Pixel 8)");
|
|
process.assert(contains(apage, "<section class=\"detected\"><h2>Android"),
|
|
"Android UA marks the Android section");
|
|
print(" UA detection: device platform marked first\n");
|
|
|
|
// ── 404s + index links ─────────────────────────────────────────────
|
|
process.assert(fetch_code("/install/nope/stable") == "404", "unknown slug is 404");
|
|
ue := parse_body(fetch("/install/nope/stable"), "unknown slug", xx arena);
|
|
process.assert(get_str(get_obj(ue, "error"), "code") == "install.unknown_app", "names install.unknown_app");
|
|
ce := parse_body(fetch("/install/acme-app/nope"), "unknown channel", xx arena);
|
|
process.assert(get_str(get_obj(ce, "error"), "code") == "install.unknown_channel", "names install.unknown_channel");
|
|
process.assert(fetch_code("/install/acme-app") == "404", "channel-less install path is 404");
|
|
idx := fetch("/");
|
|
process.assert(contains(idx, "href=\"/install/acme-app/stable\""), "index links the install page");
|
|
print(" 404s precise; index links install pages\n");
|
|
|
|
// ── teardown ───────────────────────────────────────────────────────
|
|
process.run(concat("kill ", pid));
|
|
process.run(concat("rm -rf ", STORE));
|
|
print("server_install: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|