Improve MineGeo exploration and mapping features
This commit is contained in:
116
tests/account.test.ts
Normal file
116
tests/account.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
authActionSchema,
|
||||
authDestination,
|
||||
displayNameSchema,
|
||||
newPasswordSchema,
|
||||
profileUpdateSchema,
|
||||
} from "../shared/account";
|
||||
import {
|
||||
createRecoveryGrant,
|
||||
verifyRecoveryGrant,
|
||||
} from "../shared/password-recovery";
|
||||
test("display names allow Unicode and spaces, normalize surrounding whitespace, reject hidden controls", () => {
|
||||
assert.equal(displayNameSchema.parse(" Pavel (: "), "Pavel (:");
|
||||
assert.equal(displayNameSchema.parse("Исследователь"), "Исследователь");
|
||||
for (const bad of [
|
||||
"A",
|
||||
"a".repeat(33),
|
||||
"Name\nAdmin",
|
||||
"Name\u202eadmin",
|
||||
"\u0000Explorer",
|
||||
])
|
||||
assert.equal(displayNameSchema.safeParse(bad).success, false);
|
||||
assert.equal(
|
||||
profileUpdateSchema.safeParse({
|
||||
displayName: "Explorer",
|
||||
role: "moderator",
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
profileUpdateSchema.safeParse({ displayName: "Explorer", id: "other-user" })
|
||||
.success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("new passwords require 12 characters and fit bcrypt UTF-8 input without trimming", () => {
|
||||
assert.equal(newPasswordSchema.safeParse("short").success, false);
|
||||
assert.equal(
|
||||
newPasswordSchema.parse(" a passphrase with spaces "),
|
||||
" a passphrase with spaces ",
|
||||
);
|
||||
assert.equal(newPasswordSchema.safeParse("x".repeat(72)).success, true);
|
||||
assert.equal(newPasswordSchema.safeParse("x".repeat(73)).success, false);
|
||||
assert.equal(newPasswordSchema.safeParse("🌍".repeat(20)).success, false);
|
||||
// Existing accounts can log in using their original password policy.
|
||||
assert.equal(
|
||||
authActionSchema.safeParse({
|
||||
action: "login",
|
||||
email: "user@example.com",
|
||||
password: "oldpass",
|
||||
}).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
authActionSchema.safeParse({
|
||||
action: "signup",
|
||||
email: "user@example.com",
|
||||
password: "oldpass",
|
||||
displayName: "Explorer",
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("authentication redirects accept supported local routes and reject open redirects", () => {
|
||||
for (const next of [
|
||||
"https://evil.example",
|
||||
"//evil.example",
|
||||
"/\\evil.example",
|
||||
"/%2f%2fevil.example",
|
||||
"/dashboard?next=https://evil.example",
|
||||
"javascript:alert(1)",
|
||||
])
|
||||
assert.equal(authDestination(next), "/dashboard");
|
||||
assert.equal(authDestination("/settings"), "/settings");
|
||||
assert.equal(authDestination("/pair?code=1234ABCD"), "/pair?code=1234ABCD");
|
||||
assert.equal(authDestination("/auth/reset-password"), "/auth/reset-password");
|
||||
});
|
||||
test("recovery grants bind account, session, signature and a ten-minute expiry", () => {
|
||||
const now = 1700000000000,
|
||||
key = "test-only-secret",
|
||||
token = createRecoveryGrant("account", "session", key, now);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(token, "account", "session", key, now + 1000),
|
||||
true,
|
||||
);
|
||||
for (const [user, session, secret, time] of [
|
||||
["other", "session", key, now],
|
||||
["account", "other", key, now],
|
||||
["account", "session", "wrong", now],
|
||||
["account", "session", key, now + 600000],
|
||||
] as const)
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(token, user, session, secret, time),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(
|
||||
token.replace("1700000600", "1700009999"),
|
||||
"account",
|
||||
"session",
|
||||
key,
|
||||
now,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(undefined, "account", "session", key, now),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant("malformed", "account", "session", key, now),
|
||||
false,
|
||||
);
|
||||
});
|
||||
103
tests/contributions.test.ts
Normal file
103
tests/contributions.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
datasetSchema,
|
||||
parseUploadManifest,
|
||||
panoramaPath,
|
||||
} from "../shared/protocol";
|
||||
import { selectDraftLocations } from "../shared/draft-selection";
|
||||
const root = "11111111-1111-4111-8111-111111111111",
|
||||
child = "22222222-2222-4222-8222-222222222222";
|
||||
const point = (i: number) => ({ id: `p${i}`, x: i, y: 65, z: i });
|
||||
const manifest = {
|
||||
format: "minegeo-dataset/v1",
|
||||
minecraftVersion: "1.21.11",
|
||||
dimension: "minecraft:overworld",
|
||||
source: "plugin",
|
||||
name: "Random world",
|
||||
serverName: "Test server",
|
||||
north: "-z",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 24 }, (_, i) => point(i)),
|
||||
assets: [{ path: "terrain.json.gz", bytes: 10, sha256: "a".repeat(64) }],
|
||||
};
|
||||
test("maps permit 24 captures and reject a 25th, including deselected locations", () => {
|
||||
assert.equal(parseUploadManifest(manifest).viewpoints.length, 24);
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({
|
||||
...manifest,
|
||||
viewpoints: [...manifest.viewpoints, point(24)],
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
datasetSchema.parse({
|
||||
...manifest,
|
||||
viewpoints: manifest.viewpoints.slice(0, 5),
|
||||
availableViewpoints: [...manifest.viewpoints, point(24)],
|
||||
}),
|
||||
);
|
||||
});
|
||||
test("contributions can contain one panorama, while new maps still need five", () => {
|
||||
const contribution = parseUploadManifest({
|
||||
...manifest,
|
||||
contributionTo: root,
|
||||
viewpoints: [point(0)],
|
||||
});
|
||||
assert.equal(selectDraftLocations(contribution, ["p0"]).viewpoints.length, 1);
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({ ...manifest, viewpoints: [point(0)] }),
|
||||
);
|
||||
});
|
||||
test("clients cannot choose other uploads' media or claim server-assigned provenance", () => {
|
||||
for (const field of ["assetRoot", "assetId", "contributorId", "source"])
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({
|
||||
...manifest,
|
||||
viewpoints: manifest.viewpoints.map((p) => ({
|
||||
...p,
|
||||
[field]:
|
||||
field === "assetId" ? "p0" : field === "source" ? "fabric" : child,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({
|
||||
...manifest,
|
||||
availableViewpoints: manifest.viewpoints,
|
||||
}),
|
||||
);
|
||||
});
|
||||
test("merged Fabric maps preserve external cubemaps without requiring them in the original capture", () => {
|
||||
const external = {
|
||||
...point(5),
|
||||
id: "c_child_1",
|
||||
assetRoot: child,
|
||||
assetId: "p0",
|
||||
source: "plugin" as const,
|
||||
};
|
||||
const local = manifest.viewpoints.slice(0, 5);
|
||||
const merged = datasetSchema.parse({
|
||||
...manifest,
|
||||
source: "fabric",
|
||||
viewpoints: [...local, external],
|
||||
assets: [
|
||||
"map.png",
|
||||
...local.flatMap((p) =>
|
||||
["px", "nx", "py", "ny", "pz", "nz"].map((f) => `${p.id}/${f}.png`),
|
||||
),
|
||||
].map((path) => ({ path, bytes: 10, sha256: "a".repeat(64) })),
|
||||
});
|
||||
assert.equal(
|
||||
panoramaPath(root, merged.viewpoints[5], "nz.webp"),
|
||||
`${child}/p0/nz.webp`,
|
||||
);
|
||||
assert.equal(
|
||||
panoramaPath(root, merged.viewpoints[0], "preview.webp"),
|
||||
`${root}/p0/preview.webp`,
|
||||
);
|
||||
const selection = selectDraftLocations(
|
||||
merged,
|
||||
local.map((p) => p.id),
|
||||
);
|
||||
assert.equal(selection.availableViewpoints?.length, 6);
|
||||
});
|
||||
17
tests/java/ServerAddressSmoke.java
Normal file
17
tests/java/ServerAddressSmoke.java
Normal file
@@ -0,0 +1,17 @@
|
||||
import dev.minegeo.plugin.ServerAddress;
|
||||
public class ServerAddressSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ServerAddress.requireMatch(" PLAY.EXAMPLE.COM.:25565 ","play.example.com");
|
||||
ServerAddress.requireMatch("[2001:DB8::1]","[2001:db8::1]:25565");
|
||||
ServerAddress.requireMatch(null,"other.example.com");
|
||||
for(String capture:new String[]{"other.example.com","play.example.com:25566",""}) {
|
||||
try {ServerAddress.requireMatch("play.example.com",capture);throw new AssertionError("Wrong server accepted");}
|
||||
catch(java.io.IOException expected) {}
|
||||
}
|
||||
for(String invalid:new String[]{"https://host","host:0","host:65536","a..b","[:::]"}) {
|
||||
try {ServerAddress.normalize(invalid);throw new AssertionError("Invalid address accepted");}
|
||||
catch(IllegalArgumentException expected) {}
|
||||
}
|
||||
System.out.println("Server address checks passed for the shared mod/plugin client");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ import java.nio.file.*;
|
||||
public class UploadSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String token=JsonParser.parseString(Files.readString(Path.of(args[2]))).getAsJsonObject().get("token").getAsString();
|
||||
new UploadClient(args[0],token).upload(Path.of(args[1]),message->System.out.println(message),()->false);
|
||||
new UploadClient(args[0],token).upload(Path.of(args[1]),args.length>3?args[3]:null,message->System.out.println(message),()->false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,22 @@ async function run(
|
||||
published?: boolean;
|
||||
active?: boolean;
|
||||
jobConflict?: boolean;
|
||||
contribution?: boolean;
|
||||
mixed?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const dataset = {
|
||||
...baseDataset,
|
||||
...(options.published ? { state: "published", revision_id: id } : {}),
|
||||
...(options.contribution ? { parent_dataset_id: id } : {}),
|
||||
};
|
||||
const job = { ...baseJob, ...(options.active ? { state: "running" } : {}) };
|
||||
const input = { validated: true, manifest: {} };
|
||||
const input = {
|
||||
validated: true,
|
||||
manifest: {
|
||||
viewpoints: [{ id: "p0", ...(options.mixed ? { assetRoot: id } : {}) }],
|
||||
},
|
||||
};
|
||||
const patches: { table: string; body: Record<string, unknown> }[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
@@ -124,6 +132,17 @@ test("reprocessing cannot mutate published revisions", async (t) => {
|
||||
assert.match(result.output, /Published revisions cannot be changed/);
|
||||
assert.deepEqual(result.patches, []);
|
||||
});
|
||||
test("accepted contribution media cannot be overwritten through reprocessing", async (t) => {
|
||||
for (const options of [{ contribution: true }, { mixed: true }]) {
|
||||
const result = await run(t, options);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.match(
|
||||
result.output,
|
||||
/Contributions cannot be overwritten|accepted contributions/,
|
||||
);
|
||||
assert.deepEqual(result.patches, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("reprocessing cannot take over an active worker job", async (t) => {
|
||||
const result = await run(t, { active: true });
|
||||
|
||||
43
tests/server-address.test.ts
Normal file
43
tests/server-address.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeServerAddress } from "../shared/server-address";
|
||||
test("Minecraft addresses compare case, trailing DNS dot and default port consistently", () => {
|
||||
for (const value of [
|
||||
"play.example.com",
|
||||
" PLAY.EXAMPLE.COM.:25565 ",
|
||||
"play.example.com:25565",
|
||||
])
|
||||
assert.equal(normalizeServerAddress(value), "play.example.com");
|
||||
assert.equal(
|
||||
normalizeServerAddress("192.168.0.120:25566"),
|
||||
"192.168.0.120:25566",
|
||||
);
|
||||
assert.equal(normalizeServerAddress("[2001:DB8::1]:25565"), "[2001:db8::1]");
|
||||
assert.equal(normalizeServerAddress("[::1]:25566"), "[::1]:25566");
|
||||
assert.equal(
|
||||
normalizeServerAddress("play.example.com:00080"),
|
||||
"play.example.com:80",
|
||||
);
|
||||
});
|
||||
test("clearing the server address disables its check", () => {
|
||||
for (const value of [null, undefined, "", " "])
|
||||
assert.equal(normalizeServerAddress(value), null);
|
||||
});
|
||||
test("addresses reject URLs, paths, credentials, invalid hosts and ports", () => {
|
||||
for (const value of [
|
||||
"https://play.example.com",
|
||||
"player@host",
|
||||
"host/path",
|
||||
"host?query",
|
||||
"host:0",
|
||||
"host:65536",
|
||||
"host:abcdef",
|
||||
"a..b",
|
||||
"-host",
|
||||
"host-",
|
||||
"[abc]",
|
||||
"[:::]",
|
||||
"a".repeat(64),
|
||||
])
|
||||
assert.throws(() => normalizeServerAddress(value), value);
|
||||
});
|
||||
61
tests/sql/contribution-server.sql
Normal file
61
tests/sql/contribution-server.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Integration checks against a database with two existing test/seed accounts.
|
||||
-- All fixture rows and edits are rolled back; no storage uploads or game revisions.
|
||||
begin;
|
||||
do $$
|
||||
declare owner uuid; player uuid; root uuid; child uuid; m jsonb; cm jsonb; info jsonb;
|
||||
begin
|
||||
select id into owner from public.profiles where role='moderator' limit 1;
|
||||
select id into player from public.profiles where id<>owner limit 1;
|
||||
if owner is null or player is null then raise exception 'Seed two accounts before running this test'; end if;
|
||||
m:=jsonb_build_object('format','minegeo-dataset/v1','minecraftVersion','1.21.11','dimension','minecraft:overworld',
|
||||
'source','plugin','name','Server check rollback fixture','serverName','Server check rollback fixture','serverAddress',' PLAY.EXAMPLE.COM.:25565 ',
|
||||
'north','-z','bounds',jsonb_build_object('minX',0,'minZ',0,'maxX',64,'maxZ',64),
|
||||
'viewpoints',(select jsonb_agg(jsonb_build_object('id','p'||n,'x',n,'y',65,'z',n)) from generate_series(1,5) n),
|
||||
'assets',jsonb_build_array(jsonb_build_object('path','terrain.json.gz','bytes',1,'sha256',repeat('a',64))));
|
||||
root:=public.create_upload(owner,m);
|
||||
update public.datasets set state='draft' where id=root;
|
||||
update public.dataset_inputs set validated=true where dataset_id=root;
|
||||
perform public.set_contributions(root,owner,true);
|
||||
info:=public.contribution_capacity(root,player);
|
||||
if info->>'serverAddress'<>'play.example.com' then raise exception 'Initial address not canonical'; end if;
|
||||
cm:=m||jsonb_build_object('contributionTo',root,'viewpoints',jsonb_build_array(m->'viewpoints'->0));
|
||||
begin
|
||||
perform public.create_upload(player,cm||jsonb_build_object('serverAddress','other.example.com'));
|
||||
raise exception 'Mismatch accepted';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
begin
|
||||
perform public.create_upload(player,cm-'serverAddress');
|
||||
raise exception 'Missing capture address accepted';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
begin
|
||||
perform public.set_contribution_server(root,player,null);
|
||||
raise exception 'Non-owner changed address';
|
||||
exception when others then if sqlerrm<>'Map owner permission required' then raise; end if; end;
|
||||
child:=public.create_upload(player,cm||jsonb_build_object('serverAddress','play.example.com'));
|
||||
perform public.set_contribution_server(root,owner,'other.example.com:25566');
|
||||
begin
|
||||
perform public.finalize_upload(child,player);
|
||||
raise exception 'Retry finalized after the server changed';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
update public.datasets set state='draft',panorama_count=1 where id=child;
|
||||
update public.dataset_inputs set validated=true where dataset_id=child;
|
||||
begin
|
||||
perform public.submit_draft(child,player);
|
||||
raise exception 'Mismatching submission accepted';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
perform public.set_contribution_server(root,owner,'');
|
||||
perform public.submit_draft(child,player);
|
||||
perform public.set_contribution_server(root,owner,'other.example.com');
|
||||
begin
|
||||
perform public.review_contribution(root,child,owner,'accept');
|
||||
raise exception 'Owner accepted mismatching contribution while check enabled';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
perform public.set_contribution_server(root,owner,null);
|
||||
perform public.review_contribution(root,child,owner,'accept');
|
||||
if (select contribution_status from public.datasets where id=child)<>'accepted' then raise exception 'Cleared check did not allow owner approval'; end if;
|
||||
if public.normalize_server_address('[2001:DB8::1]:25565')<>'[2001:db8::1]' then raise exception 'IPv6 normalization differs'; end if;
|
||||
if has_function_privilege('anon','public.set_contribution_server(uuid,uuid,text)','execute') or
|
||||
has_function_privilege('authenticated','public.check_contribution_server(uuid,uuid)','execute') then raise exception 'Private RPC exposed'; end if;
|
||||
end; $$;
|
||||
select 'server address enforcement, overrides, opt-out, retries, approvals and permissions passed' as result;
|
||||
rollback;
|
||||
Reference in New Issue
Block a user